diff --git a/DomDefsGenerator.mill b/DomDefsGenerator.mill index a4c1719..0f85698 100644 --- a/DomDefsGenerator.mill +++ b/DomDefsGenerator.mill @@ -27,6 +27,7 @@ class DomDefsGenerator(baseOutputDirectoryPath: String): override def scalaJsDomImport: String = "" override def tagKeysPackagePath: String = "scalive" override def keysPackagePath: String = "scalive" + override def generateTagsTrait( tagType: TagType, defGroups: List[(String, List[TagDef])], @@ -186,7 +187,14 @@ class DomDefsGenerator(baseOutputDirectoryPath: String): val fileContent = generator.generateAttrsTrait( defGroups = defGroups.htmlAttrDefGroups.appended( - "Reflected Attributes" -> ReflectedHtmlAttrDefs.defs.map(_.toAttrDef) + "Reflected Attributes" -> ReflectedHtmlAttrDefs.defs + .map(d => + d.scalaName match + case "defaultChecked" => d.copy(scalaName = "checked") + case "defaultSelected" => d.copy(scalaName = "selected") + case "defaultValue" => d.copy(scalaName = "value") + case _ => d + ).map(_.toAttrDef) ), printDefGroupComments = false, traitCommentLines = Nil, diff --git a/core/src/scalive/DiffBuilder.scala b/core/src/scalive/DiffBuilder.scala index 4b34a7c..affd773 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -44,10 +44,11 @@ object DiffBuilder: case Some((entries, keysCount)) => val static = entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) + val includeStatic = !trackUpdates || keysCount == entries.count(_._2.isDefined) List( Some( Diff.Comprehension( - static = if trackUpdates then Seq.empty else static, + static = if includeStatic then static else Seq.empty, entries = entries.map { case (key, Some(el)) => Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) @@ -58,7 +59,6 @@ object DiffBuilder: ) ) case None => List(None) - } end DiffBuilder diff --git a/core/src/scalive/Dyn.scala b/core/src/scalive/Dyn.scala index cfc0884..41ced5a 100644 --- a/core/src/scalive/Dyn.scala +++ b/core/src/scalive/Dyn.scala @@ -37,14 +37,17 @@ sealed trait Dyn[T]: private[scalive] def callOnEveryChild(f: T => Unit): Unit extension [T](parent: Dyn[List[T]]) - def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod = + // TODO fix + def splitBy[Key](key: T => Key)(project: (Key, Dyn[T]) => HtmlElement): Mod = Mod.Content.DynSplit( new SplitVar( - parent.apply(_.zipWithIndex), - key = _._2, - project = (index, v) => project(index, v(_._1)) + parent, + key = key, + project = (k, v) => project(k, v) ) ) + def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod = + parent(_.zipWithIndex).splitBy(_._2)((index, v) => project(index, v(_._1))) private class Var[T] private (initial: T) extends Dyn[T]: private[scalive] var currentValue: T = initial @@ -93,7 +96,6 @@ private class SplitVar[I, O, Key]( key: I => Key, project: (Key, Dyn[I]) => O): - // Deleted elements have value none private val memoized: mutable.Map[Key, (Var[I], O)] = mutable.Map.empty @@ -141,4 +143,6 @@ private class SplitVar[I, O, Key]( private[scalive] def callOnEveryChild(f: O => Unit): Unit = memoized.values.foreach((_, output) => f(output)) + private[scalive] def currentValues: Iterable[O] = memoized.values.map(_._2) + end SplitVar diff --git a/core/src/scalive/HtmlElement.scala b/core/src/scalive/HtmlElement.scala index 9ac4a0a..a411565 100644 --- a/core/src/scalive/HtmlElement.scala +++ b/core/src/scalive/HtmlElement.scala @@ -1,11 +1,15 @@ package scalive +import scalive.JSCommands.JSCommand import scalive.Mod.Attr import scalive.Mod.Content import scalive.codecs.BooleanAsAttrPresenceEncoder import scalive.codecs.Encoder import zio.json.* +import java.util.Base64 +import scala.util.Random + class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): def static: Seq[String] = StaticBuilder.build(this) def attrMods: Seq[Mod.Attr] = @@ -25,6 +29,8 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod)) def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod)) + def findBinding[Msg](id: String): Option[Map[String, String] => Msg] = + mods.iterator.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f } private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll()) private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged()) @@ -62,13 +68,30 @@ class HtmlAttr[V](val name: String, val codec: Encoder[V, String]): ) else Mod.Attr.Dyn(name, value(codec.encode)) -class HtmlAttrJsonValue(val name: String): +class HtmlAttrBinding(val name: String): + def apply(cmd: JSCommand): Mod.Attr = + Mod.Attr.JsBinding(name, cmd.toJson, cmd.bindings) - def :=[V: JsonEncoder](value: V): Mod.Attr = - Mod.Attr.Static(name, value.toJson, isJson = true) + def apply[Msg](msg: Msg): Mod.Attr = + apply(_ => msg) - def :=[V: JsonEncoder](value: Dyn[V]): Mod.Attr = - Mod.Attr.Dyn(name, value(_.toJson), isJson = true) + def apply[Msg](f: Map[String, String] => Msg): Mod.Attr = + Mod.Attr.Binding( + name, + Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12)), + f + ) + def withValue[Msg](f: String => Msg): Mod.Attr = + apply(m => f(m("value"))) + +trait BindingAdapter[F, Msg]: + def createMessage(f: F): Map[String, String] => Msg +object BindingAdapter: + given fromString[Msg]: BindingAdapter[String => Msg, Msg] = f => m => f(m("value")) + given fromMap[Msg]: BindingAdapter[Map[String, String] => Msg, Msg] = f => f + +final case class BindingParams(params: Map[String, String]): + def apply(key: String) = params.apply(key) sealed trait Mod sealed trait StaticMod extends Mod @@ -76,8 +99,12 @@ sealed trait DynamicMod extends Mod object Mod: enum Attr extends Mod: - case Static(name: String, value: String, isJson: Boolean = false) extends Attr with StaticMod - case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod + case Static(name: String, value: String) extends Attr with StaticMod + case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod + case Binding(name: String, id: String, f: Map[String, String] => ?) extends Attr with StaticMod + case JsBinding(name: String, jsonValue: String, bindings: Map[String, ?]) + extends Attr + with StaticMod case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false) extends Attr with DynamicMod @@ -96,7 +123,9 @@ object Mod: extension (mod: Mod) private[scalive] def setAllUnchanged(): Unit = mod match - case Attr.Static(_, _, _) => () + case Attr.Static(_, _) => () + case Attr.Binding(_, _, _) => () + case Attr.JsBinding(_, _, _) => () case Attr.StaticValueAsPresence(_, _) => () case Attr.Dyn(_, value, _) => value.setUnchanged() case Attr.DynValueAsPresence(_, value) => value.setUnchanged() @@ -118,8 +147,10 @@ extension (mod: Mod) private[scalive] def syncAll(): Unit = mod match - case Attr.Static(_, _, _) => () + case Attr.Static(_, _) => () case Attr.StaticValueAsPresence(_, _) => () + case Attr.Binding(_, _, _) => () + case Attr.JsBinding(_, _, _) => () case Attr.Dyn(_, value, _) => value.sync() case Attr.DynValueAsPresence(_, value) => value.sync() case Content.Text(text) => () @@ -137,4 +168,25 @@ extension (mod: Mod) case Content.DynSplit(v) => v.sync() v.callOnEveryChild(_.syncAll()) + + private[scalive] def findBinding[Msg](id: String): Option[Map[String, String] => Msg] = + mod match + case Attr.Static(_, _) => None + case Attr.StaticValueAsPresence(_, _) => None + case Attr.Binding(_, eventId, f) => + if id == eventId then Some(f.asInstanceOf[Map[String, String] => Msg]) + else None + case Attr.JsBinding(_, _, bindings) => + bindings.get(id).map(msg => _ => msg.asInstanceOf[Msg]) + case Attr.Dyn(_, value, _) => None + case Attr.DynValueAsPresence(_, value) => None + case Content.Text(text) => None + case Content.Tag(el) => el.findBinding(id) + case Content.DynText(dyn) => None + case Content.DynElement(dyn) => dyn.currentValue.findBinding(id) + case Content.DynOptionElement(dyn) => dyn.currentValue.flatMap(_.findBinding(id)) + case Content.DynElementColl(dyn) => + dyn.currentValue.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f } + case Content.DynSplit(v) => + v.currentValues.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f } end extension diff --git a/core/src/scalive/JS.scala b/core/src/scalive/JS.scala index 09256b1..e788105 100644 --- a/core/src/scalive/JS.scala +++ b/core/src/scalive/JS.scala @@ -3,16 +3,21 @@ package scalive import zio.json.* import zio.json.ast.Json +import java.util.Base64 +import scala.util.Random + val JS: JSCommands.JSCommand = JSCommands.empty object JSCommands: - opaque type JSCommand = List[Json] + opaque type JSCommand = List[(Json, Option[Binding[?]])] + + final case class Binding[Msg](id: String, msg: Msg) def empty: JSCommand = List.empty object JSCommand: given JsonEncoder[JSCommand] = - JsonEncoder[Json].contramap(ops => Json.Arr(ops.reverse*)) + JsonEncoder[Json].contramap(ops => Json.Arr(ops.map(_._1).reverse*)) private def classNames(names: String): Seq[String] = names.split("\\s+") private def transitionClasses(names: String | (String, String, String)) @@ -24,7 +29,15 @@ object JSCommands: extension (ops: JSCommand) private def addOp[A: JsonEncoder](kind: String, args: A): JSCommand = - (kind, args).toJsonAST.fold(e => throw new IllegalArgumentException(e), identity) :: ops + (kind, args).toJsonAST.fold(e => throw new IllegalArgumentException(e), (_, None)) :: ops + + private def addOp[A: JsonEncoder, Msg](kind: String, args: A, binding: Binding[Msg]) + : JSCommand = + (kind, args).toJsonAST + .fold(e => throw new IllegalArgumentException(e), (_, Some(binding))) :: ops + + private[scalive] def bindings: Map[String, ?] = + ops.flatMap(_._2).map(b => (b.id, b.msg)).toMap def addClass = ClassOp("add_class", ops) def toggleClass = ClassOp("toggle_class", ops) @@ -100,20 +113,22 @@ object JSCommands: def popFocus() = ops.addOp("pop_focus", Json.Obj.empty) - def push[A: JsonEncoder]( - event: A, + def push[Msg]( + event: Msg, target: String = "", loading: String = "", pageLoading: Boolean = false ) = + val bindingId = Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12)) ops.addOp( "push", Args.Push( - event.toJson, + bindingId, Option.when(target.nonEmpty)(target), Option.when(loading.nonEmpty)(loading), Option.when(!pageLoading)(pageLoading) - ) + ), + Binding(bindingId, event) ) def pushFocus(to: String = "") = diff --git a/core/src/scalive/Scalive.scala b/core/src/scalive/Scalive.scala index ae7e09f..6a5ee12 100644 --- a/core/src/scalive/Scalive.scala +++ b/core/src/scalive/Scalive.scala @@ -22,8 +22,8 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: new HtmlAttr(s"phx-$suffix", BooleanAsTrueFalseStringEncoder) private def phxAttrInt(suffix: String): HtmlAttr[Int] = new HtmlAttr(s"phx-$suffix", IntAsStringEncoder) - private def phxAttrJson(suffix: String): HtmlAttrJsonValue = - new HtmlAttrJsonValue(s"phx-$suffix") + private def phxAttrBinding(suffix: String): HtmlAttrBinding = + new HtmlAttrBinding(s"phx-$suffix") private def dataPhxAttr(suffix: String): HtmlAttr[String] = dataAttr(s"phx-$suffix") @@ -33,41 +33,44 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: private[scalive] lazy val linkState = dataPhxAttr("link-state") // Click - lazy val click = phxAttrJson("click") - lazy val clickAway = phxAttrJson("click-away") + lazy val onClick = phxAttrBinding("click") + lazy val onClickAway = phxAttrBinding("click-away") + // Focus - lazy val blur = phxAttrJson("blur") - lazy val focus = phxAttrJson("focus") - lazy val windowBlur = phxAttrJson("window-blur") + lazy val onBlur = phxAttrBinding("blur") + lazy val onFocus = phxAttrBinding("focus") + lazy val onWindowBlur = phxAttrBinding("window-blur") // Keyboard - lazy val keydown = phxAttrJson("keydown") - lazy val keyup = phxAttrJson("keyup") - lazy val windowKeydown = phxAttrJson("window-keydown") - lazy val windowKeyup = phxAttrJson("window-keyup") - lazy val key = phxAttr("key") + lazy val onKeydown = phxAttrBinding("keydown") + lazy val onKeyup = phxAttrBinding("keyup") + lazy val onWindowKeydown = phxAttrBinding("window-keydown") + lazy val onWindowKeyup = phxAttrBinding("window-keyup") + // For accepted values, see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values + lazy val key = phxAttr("key") // Scroll - lazy val viewportTop = phxAttrJson("viewport-top") - lazy val viewportBottom = phxAttrJson("viewport-bottom") + lazy val onViewportTop = phxAttrBinding("viewport-top") + lazy val onViewportBottom = phxAttrBinding("viewport-bottom") // Form - lazy val change = phxAttrJson("change") - lazy val submit = phxAttrJson("submit") - lazy val autoRecover = phxAttrJson("auto-recover") + lazy val onChange = phxAttrBinding("change") + lazy val onSubmit = phxAttrBinding("submit") + lazy val autoRecover = phxAttrBinding("auto-recover") lazy val triggerAction = phxAttrBool("trigger-action") // Button lazy val disableWith = phxAttr("disable-with") // Socket connection lifecycle - lazy val connected = phxAttrJson("connected") - lazy val disconnected = phxAttrJson("disconnected") + lazy val onConnected = phxAttrBinding("connected") + lazy val onDisconnected = phxAttrBinding("disconnected") // DOM element lifecycle - lazy val mounted = phxAttrJson("mounted") - lazy val remove = phxAttrJson("remove") - lazy val update = new HtmlAttr["update" | "stream" | "ignore"](s"phx-update", Encoder(identity)) + lazy val onMounted = phxAttrBinding("mounted") + lazy val onRemove = phxAttrBinding("remove") + lazy val onUpdate = + new HtmlAttr["update" | "stream" | "ignore"](s"phx-update", Encoder(identity)) // Client hooks lazy val hook = phxAttr("hook") diff --git a/core/src/scalive/StaticBuilder.scala b/core/src/scalive/StaticBuilder.scala index 2db1bf0..ef90ef5 100644 --- a/core/src/scalive/StaticBuilder.scala +++ b/core/src/scalive/StaticBuilder.scala @@ -12,10 +12,10 @@ object StaticBuilder: private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = val attrs = el.attrMods.flatMap { - case Attr.Static(name, value, isJson) => - if isJson then List(Some(s" $name='$value'")) - else List(Some(s""" $name="$value"""")) + case Attr.Static(name, value) => List(Some(s" $name='$value'")) case Attr.StaticValueAsPresence(name, value) => List(Some(s" $name")) + case Attr.Binding(name, id, _) => List(Some(s""" $name="$id"""")) + case Attr.JsBinding(name, json, _) => List(Some(s" $name='$json'")) case Attr.Dyn(name, value, isJson) => if isJson then List(Some(s" $name='"), None, Some("'")) else List(Some(s""" $name=""""), None, Some('"'.toString)) diff --git a/core/src/scalive/codecs/Encoder.scala b/core/src/scalive/codecs/Encoder.scala index da7ced1..5f14e74 100644 --- a/core/src/scalive/codecs/Encoder.scala +++ b/core/src/scalive/codecs/Encoder.scala @@ -15,6 +15,9 @@ lazy val DoubleAsIsEncoder: Encoder[Double, Double] = AsIsEncoder() lazy val DoubleAsStringEncoder: Encoder[Double, String] = Encoder[Double, String](_.toString) +lazy val BooleanAsStringEncoder: Encoder[Boolean, String] = + Encoder[Boolean, String](_.toString) + val BooleanAsIsEncoder: Encoder[Boolean, Boolean] = AsIsEncoder() lazy val BooleanAsAttrPresenceEncoder: Encoder[Boolean, String] = diff --git a/example/src/CounterLiveView.scala b/example/src/CounterLiveView.scala index 64fb90e..a4e98d0 100644 --- a/example/src/CounterLiveView.scala +++ b/example/src/CounterLiveView.scala @@ -2,7 +2,6 @@ import CounterLiveView.* import monocle.syntax.all.* import scalive.* import zio.* -import zio.json.* import zio.stream.ZStream class CounterLiveView() extends LiveView[Msg, Model]: @@ -34,8 +33,8 @@ class CounterLiveView() extends LiveView[Msg, Model]: div( cls := "flex flex-wrap items-center gap-3", button( - cls := "btn btn-default", - phx.click := Msg.ToggleCounter, + cls := "btn btn-default", + phx.onClick(Msg.ToggleCounter), model(_.isVisible match case true => "Hide counter" case false => "Show counter") @@ -45,8 +44,8 @@ class CounterLiveView() extends LiveView[Msg, Model]: div( cls := "flex items-center justify-center gap-4", button( - cls := "btn btn-neutral", - phx.click := Msg.DecCounter, + cls := "btn btn-neutral", + phx.onClick(Msg.DecCounter), "-" ), div( @@ -54,8 +53,8 @@ class CounterLiveView() extends LiveView[Msg, Model]: model(_.counter.toString) ), button( - cls := "btn btn-neutral", - phx.click := Msg.IncCounter, + cls := "btn btn-neutral", + phx.onClick(Msg.IncCounter), "+" ) ) @@ -70,7 +69,7 @@ end CounterLiveView object CounterLiveView: - enum Msg derives JsonCodec: + enum Msg: case ToggleCounter case IncCounter case DecCounter diff --git a/example/src/Example.scala b/example/src/Example.scala index 133c113..19c47ad 100644 --- a/example/src/Example.scala +++ b/example/src/Example.scala @@ -38,6 +38,10 @@ object Example extends ZIOAppDefault: (_, req) => val q = req.queryParam("q").map("Param : " ++ _).getOrElse("No param") ListLiveView(q) + ), + LiveRoute( + Root / "todo", + (_, _) => TodoLiveView() ) ) ) diff --git a/example/src/HomeLiveView.scala b/example/src/HomeLiveView.scala index 877797d..7a46948 100644 --- a/example/src/HomeLiveView.scala +++ b/example/src/HomeLiveView.scala @@ -5,7 +5,8 @@ import zio.stream.ZStream class HomeLiveView() extends LiveView[String, Unit]: val links = List( "/counter" -> "Counter", - "/list?q=test" -> "List" + "/list?q=test" -> "List", + "/todo" -> "Todo" ) def init = ZIO.succeed(()) diff --git a/example/src/ListLiveView.scala b/example/src/ListLiveView.scala index b5d9702..1bfe974 100644 --- a/example/src/ListLiveView.scala +++ b/example/src/ListLiveView.scala @@ -2,7 +2,6 @@ import ListLiveView.* import monocle.syntax.all.* import scalive.* import zio.* -import zio.json.* import zio.stream.ZStream class ListLiveView(someParam: String) extends LiveView[Msg, Model]: @@ -48,14 +47,14 @@ class ListLiveView(someParam: String) extends LiveView[Msg, Model]: div( cls := "card-actions", button( - cls := "btn btn-default", - phx.click := Msg.IncAge(1), + cls := "btn btn-default", + phx.onClick(Msg.IncAge(1)), "Inc age" ), span(cls := "grow"), button( - cls := "btn btn-neutral", - phx.click := JS.toggleClass("btn-neutral btn-accent").push(Msg.IncAge(-5)), + cls := "btn btn-neutral", + phx.onClick(JS.toggleClass("btn-neutral btn-accent").push(Msg.IncAge(-5))), "Toggle color" ) ) @@ -68,7 +67,7 @@ end ListLiveView object ListLiveView: - enum Msg derives JsonCodec: + enum Msg: case IncAge(value: Int) final case class Model( diff --git a/example/src/TodoLiveView.scala b/example/src/TodoLiveView.scala new file mode 100644 index 0000000..621beee --- /dev/null +++ b/example/src/TodoLiveView.scala @@ -0,0 +1,88 @@ +import TodoLiveView.* +import monocle.syntax.all.* +import scalive.* +import zio.* +import zio.stream.ZStream + +class TodoLiveView() extends LiveView[Msg, Model]: + + def init = ZIO.succeed(Model(List(Todo(99, "some task")))) + + def update(model: Model) = + case Msg.Add(text) => + val nextId = model.todos.maxByOption(_.id).map(_.id).getOrElse(1) + ZIO.succeed( + model + .focus(_.todos) + .modify(_.appended(Todo(nextId, text))) + ) + case Msg.Remove(id) => + ZIO.succeed( + model + .focus(_.todos) + .modify(_.filterNot(_.id == id)) + ) + case Msg.ToggleCompletion(id) => + ZIO.succeed( + model + .focus(_.todos) + .modify( + _.map(todo => if todo.id == id then todo.copy(completed = todo.completed) else todo) + ) + ) + + def view(model: Dyn[Model]) = + div( + cls := "mx-auto card bg-base-100 max-w-2xl shadow-xl space-y-6 p-6", + div( + cls := "card-body", + h1(cls := "card-title", "Todos"), + div( + cls := "flex items-center gap-3", + input( + cls := "input input-bordered grow", + typ := "text", + nameAttr := "todo-text", + placeholder := "What needs to be done?", + phx.onKeyup.withValue(Msg.Add(_)), + phx.key := "Enter", + phx.value("test") := "some value" + ) + ), + form( + cls := "flex items-center gap-3", + phx.onChange(p => Msg.Add(p("todo-text"))), + phx.onSubmit(p => Msg.Add(p("todo-text"))), + input( + cls := "input input-bordered grow", + typ := "text", + nameAttr := "todo-text", + placeholder := "What needs to be done?" + ) + ), + ul( + cls := "divide-y divide-base-200", + model(_.todos).splitByIndex((_, elem) => + li( + cls := "py-3 flex flex-wrap items-center justify-between gap-2", + elem(_.text) + ) + ) + ) + ) + ) + + def subscriptions(model: Model) = ZStream.empty +end TodoLiveView + +object TodoLiveView: + + enum Msg: + case Add(text: String) + case Remove(id: Int) + case ToggleCompletion(id: Int) + + final case class Model(todos: List[Todo] = List.empty, filter: Filter = Filter.All) + final case class Todo(id: Int, text: String, completed: Boolean = false) + enum Filter: + case All, Active, Completed diff --git a/zio/src/scalive/LiveRouter.scala b/zio/src/scalive/LiveRouter.scala index a644415..38fcf8b 100644 --- a/zio/src/scalive/LiveRouter.scala +++ b/zio/src/scalive/LiveRouter.scala @@ -14,10 +14,9 @@ import zio.stream.ZStream import java.util.Base64 import scala.util.Random -final case class LiveRoute[A, Msg: JsonCodec, Model]( +final case class LiveRoute[A, Msg, Model]( path: PathCodec[A], liveviewBuilder: (A, Request) => LiveView[Msg, Model]): - val messageCodec = JsonCodec[Msg] def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Throwable] = Method.GET / path -> handler { (params: A, req: Request) => @@ -44,7 +43,6 @@ final case class LiveRoute[A, Msg: JsonCodec, Model]( ) ) } -end LiveRoute class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]]): def diffsStream: ZStream[Any, Nothing, (Payload, Meta)] = @@ -54,7 +52,7 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]] .mergeAllUnbounded()(m.values.map(_.outbox).toList*) ).flatMapParSwitch(1)(identity) - def join[Msg: JsonCodec, Model]( + def join[Msg, Model]( id: String, token: String, lv: LiveView[Msg, Model], @@ -86,17 +84,11 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]] ZIO.logWarning(s"Tried to leave LiveView $id which doesn't exist").as(m) } - def event(id: String, value: String, meta: WebSocketMessage.Meta): UIO[Unit] = + def event(id: String, event: Payload.Event, meta: WebSocketMessage.Meta): UIO[Unit] = sockets.get.flatMap { m => m.get(id) match case Some(socket) => - socket.inbox - .offer( - value - .fromJson(using socket.messageCodec.decoder) - .getOrElse(throw new IllegalArgumentException()) - -> meta - ).unit + socket.inbox.offer(event -> meta).unit case None => ZIO.unit } @@ -173,9 +165,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo .map(route.liveviewBuilder(_, req)) .map( ZIO.logDebug(s"Joining LiveView ${route.path.toString} ${message.topic}") *> - liveChannel.join(message.topic, session, _, message.meta)( - using route.messageCodec - ) + liveChannel.join(message.topic, session, _, message.meta) ) ) .collectFirst { case Some(join) => join.map(_ => None) } @@ -185,7 +175,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo liveChannel .leave(message.topic) .as(Some(message.okReply)) - case Payload.Event(_, event, _) => + case event: Payload.Event => liveChannel .event(message.topic, event, message.meta) .map(_ => None) diff --git a/zio/src/scalive/Socket.scala b/zio/src/scalive/Socket.scala index 4abd138..94a4237 100644 --- a/zio/src/scalive/Socket.scala +++ b/zio/src/scalive/Socket.scala @@ -1,23 +1,21 @@ package scalive import scalive.WebSocketMessage.LiveResponse +import scalive.WebSocketMessage.Payload import zio.* import zio.Queue -import zio.json.* -import zio.stream.ZStream import zio.stream.SubscriptionRef -import scalive.WebSocketMessage.Payload +import zio.stream.ZStream -final case class Socket[Msg: JsonCodec, Model] private ( +final case class Socket[Msg, Model] private ( id: String, token: String, - inbox: Queue[(Msg, WebSocketMessage.Meta)], + inbox: Queue[(Payload.Event, WebSocketMessage.Meta)], outbox: ZStream[Any, Nothing, (Payload, WebSocketMessage.Meta)], - shutdown: UIO[Unit]): - val messageCodec = JsonCodec[Msg] + shutdown: UIO[Unit]) object Socket: - def start[Msg: JsonCodec, Model]( + def start[Msg, Model]( id: String, token: String, lv: LiveView[Msg, Model], @@ -25,7 +23,7 @@ object Socket: ): RIO[Scope, Socket[Msg, Model]] = ZIO.logAnnotate("lv", id) { for - inbox <- Queue.bounded[(Msg, WebSocketMessage.Meta)](4) + inbox <- Queue.bounded[(Payload.Event, WebSocketMessage.Meta)](4) outHub <- Hub.unbounded[(Payload, WebSocketMessage.Meta)] initModel <- lv.init @@ -42,10 +40,18 @@ object Socket: .flatMapParSwitch(1, 1)(identity) .map(_ -> meta.copy(messageRef = None, eventType = "diff")) - clientFiber <- clientMsgStream.runForeach { (msg, meta) => + clientFiber <- clientMsgStream.runForeach { (event, meta) => for (modelVar, el) <- ref.get - updatedModel <- lv.update(modelVar.currentValue)(msg) + f <- + ZIO + .succeed(el.findBinding(event.event)) + .someOrFail( + new IllegalArgumentException( + s"No binding found for event ID ${event.event}" + ) + ) + updatedModel <- lv.update(modelVar.currentValue)(f(event.params)) _ = modelVar.set(updatedModel) _ <- lvStreamRef.set(lv.subscriptions(updatedModel)) diff = el.diff() diff --git a/core/src/scalive/WebSocketMessage.scala b/zio/src/scalive/WebSocketMessage.scala similarity index 88% rename from core/src/scalive/WebSocketMessage.scala rename to zio/src/scalive/WebSocketMessage.scala index 903e7ff..684b1a8 100644 --- a/core/src/scalive/WebSocketMessage.scala +++ b/zio/src/scalive/WebSocketMessage.scala @@ -2,8 +2,8 @@ package scalive import scalive.WebSocketMessage.LiveResponse import scalive.WebSocketMessage.Payload -import scalive.WebSocketMessage.Payload.EventType import zio.Chunk +import zio.http.QueryParams import zio.json.* import zio.json.ast.Json @@ -87,29 +87,28 @@ object WebSocketMessage: case Close case Reply(status: String, response: LiveResponse) case Diff(diff: scalive.Diff) - case Event(`type`: Payload.EventType, event: String, value: Map[String, String]) + case Event(`type`: String, event: String, value: Json) + object Payload: given JsonCodec[Payload.Join] = JsonCodec.derived given JsonEncoder[Payload.Reply] = JsonEncoder.derived given JsonCodec[Payload.Event] = JsonCodec.derived given JsonEncoder[Payload.Diff] = JsonEncoder[scalive.Diff].contramap(_.diff) + extension (p: Payload.Event) + def params: Map[String, String] = + p.`type` match + case "form" => + QueryParams + .decode( + p.value.asString.getOrElse(throw new IllegalArgumentException()) + ).map.view.mapValues(_.head).toMap + + case _ => p.value.as[Map[String, String]].getOrElse(throw new IllegalArgumentException()) + def okReply(response: LiveResponse) = Payload.Reply("ok", response) - enum EventType: - case Click - object EventType: - given JsonCodec[EventType] = JsonCodec[String].transformOrFail( - { - case "click" => Right(Click) - case s => Left(s"Unsupported event type: $s") - }, - { case Click => - "click" - } - ) - enum LiveResponse: case Empty case InitDiff(rendered: scalive.Diff)