From 124239925d9470df40430a979644750a91ae0d99 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Wed, 27 Aug 2025 02:28:22 +0200 Subject: [PATCH] Support click events --- build.mill | 1 + core/src/TestLiveView.scala | 10 +-- core/src/main.scala | 23 ++++--- core/src/scalive/Diff.scala | 19 +++--- core/src/scalive/DiffBuilder.scala | 38 ++++++----- core/src/scalive/Dyn.scala | 34 +++++---- core/src/scalive/HtmlBuilder.scala | 12 ++-- core/src/scalive/HtmlElement.scala | 61 ++++++++++------- core/src/scalive/LiveView.scala | 5 +- core/src/scalive/Scalive.scala | 13 ++++ core/src/scalive/Socket.scala | 19 ++---- core/src/scalive/StaticBuilder.scala | 12 ++-- core/src/scalive/Token.scala | 4 +- core/test/src/scalive/LiveViewSpec.scala | 82 +++++++++++----------- example/src/ExampleLiveView.scala | 19 +++++- zio/src/scalive/LiveRouter.scala | 87 +++++++++++++++++++++--- 16 files changed, 277 insertions(+), 162 deletions(-) diff --git a/build.mill b/build.mill index ac5a193..2dacadf 100644 --- a/build.mill +++ b/build.mill @@ -37,6 +37,7 @@ object zio extends ScalaCommon: object example extends ScalaCommon: def moduleDeps = Seq(zio) + def mvnDeps = Seq(mvn"dev.optics::monocle-core:3.1.0") def scaliveBundle = Task { os.copy( diff --git a/core/src/TestLiveView.scala b/core/src/TestLiveView.scala index a1cd6e9..0cad63c 100644 --- a/core/src/TestLiveView.scala +++ b/core/src/TestLiveView.scala @@ -6,13 +6,13 @@ final case class MyModel( elems: List[Elem] = List.empty) final case class Elem(name: String, age: Int) -class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]: - import TestView.Cmd.* +class TestView(initialModel: MyModel) extends LiveView[String, TestView.Event]: + import TestView.Event.* private val modelVar = Var[MyModel](initialModel) - def handleCommand(cmd: TestView.Cmd): Unit = - cmd match + override def handleServerEvent(e: TestView.Event): Unit = + e match case UpdateModel(f) => modelVar.update(f) val el: HtmlElement = @@ -33,5 +33,5 @@ class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]: ) object TestView: - enum Cmd: + enum Event: case UpdateModel(f: MyModel => MyModel) diff --git a/core/src/main.scala b/core/src/main.scala index 984d86e..f2f9e32 100644 --- a/core/src/main.scala +++ b/core/src/main.scala @@ -1,4 +1,5 @@ import scalive.* +import zio.json.JsonCodec @main def main = @@ -9,21 +10,21 @@ def main = Elem("c", 30) ) ) - val s = Socket(TestView(initModel)) + val s = Socket("", "", TestView(initModel)) println("Init") println(s.renderHtml()) s.syncClient s.syncClient println("Edit class attribue") - s.receiveCommand( - TestView.Cmd.UpdateModel(_.copy(cls = "text-lg")) + s.lv.handleServerEvent( + TestView.Event.UpdateModel(_.copy(cls = "text-lg")) ) s.syncClient println("Edit first and last") - s.receiveCommand( - TestView.Cmd.UpdateModel( + s.lv.handleServerEvent( + TestView.Event.UpdateModel( _.copy(elems = List( Elem("x", 10), @@ -37,8 +38,8 @@ def main = println(s.renderHtml()) println("Add one") - s.receiveCommand( - TestView.Cmd.UpdateModel( + s.lv.handleServerEvent( + TestView.Event.UpdateModel( _.copy(elems = List( Elem("x", 10), @@ -53,8 +54,8 @@ def main = println(s.renderHtml()) println("Remove first") - s.receiveCommand( - TestView.Cmd.UpdateModel( + s.lv.handleServerEvent( + TestView.Event.UpdateModel( _.copy(elems = List( Elem("b", 15), @@ -68,8 +69,8 @@ def main = println(s.renderHtml()) println("Remove all") - s.receiveCommand( - TestView.Cmd.UpdateModel( + s.lv.handleServerEvent( + TestView.Event.UpdateModel( _.copy( cls = "text-lg", bool = false, diff --git a/core/src/scalive/Diff.scala b/core/src/scalive/Diff.scala index 948f45b..f6819d9 100644 --- a/core/src/scalive/Diff.scala +++ b/core/src/scalive/Diff.scala @@ -10,7 +10,8 @@ enum Diff: dynamic: Seq[Diff.Dynamic] = Seq.empty) case Comprehension( static: Seq[String] = Seq.empty, - entries: Seq[Diff.Dynamic] = Seq.empty) + entries: Seq[Diff.Dynamic] = Seq.empty, + count: Int = 0) case Value(value: String) case Dynamic(key: String, diff: Diff) case Deleted @@ -29,19 +30,17 @@ object Diff: dynamic.map(d => d.key -> toJson(d.diff)) ) ) - case Diff.Comprehension(static, entries) => + case Diff.Comprehension(static, entries, count) => Json.Obj( Option .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) .to(Chunk) - .appendedAll( - Option.when(entries.nonEmpty)( - "k" -> - Json - .Obj( - entries.map(d => d.key -> toJson(d.diff))* - ).add("kc", Json.Num(entries.length)) - ) + .appended( + "k" -> + Json + .Obj( + entries.map(d => d.key -> toJson(d.diff))* + ).add("kc", Json.Num(count)) ) ) case Diff.Value(value) => Json.Str(value) diff --git a/core/src/scalive/DiffBuilder.scala b/core/src/scalive/DiffBuilder.scala index 44bdadc..4b34a7c 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -23,10 +23,10 @@ object DiffBuilder: private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] = dynamicMods.flatMap { - case Attr.Dyn(attr, value) => + case Attr.Dyn(name, value, _) => List(value.render(trackUpdates).map(v => Diff.Value(v.toString))) - case Attr.DynValueAsPresence(attr, value) => - List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else ""))) + case Attr.DynValueAsPresence(name, value) => + List(value.render(trackUpdates).map(v => Diff.Value(if v then s" $name" else ""))) case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates) case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_))) case Content.DynElement(dyn) => ??? @@ -40,23 +40,25 @@ object DiffBuilder: case None => dyn.currentValue.map(build(_, trackUpdates))) case Content.DynElementColl(dyn) => ??? case Content.DynSplit(splitVar) => - val entries = splitVar.render(trackUpdates) - if entries.isEmpty then List.empty - else - val static = - entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) - List( - Some( - Diff.Comprehension( - static = if trackUpdates then Seq.empty else static, - entries = entries.map { - case (key, Some(el)) => - Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) - case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted) - } + splitVar.render(trackUpdates) match + case Some((entries, keysCount)) => + val static = + entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) + List( + Some( + Diff.Comprehension( + static = if trackUpdates then Seq.empty else static, + entries = entries.map { + case (key, Some(el)) => + Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) + case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted) + }, + count = keysCount + ) ) ) - ) + case None => List(None) + } end DiffBuilder diff --git a/core/src/scalive/Dyn.scala b/core/src/scalive/Dyn.scala index e3facb6..32e2e4d 100644 --- a/core/src/scalive/Dyn.scala +++ b/core/src/scalive/Dyn.scala @@ -94,7 +94,7 @@ class SplitVar[I, O, Key]( project: (Key, Dyn[I]) => O): // Deleted elements have value none - private val memoized: mutable.Map[Key, Option[(Var[I], O)]] = + private val memoized: mutable.Map[Key, (Var[I], O)] = mutable.Map.empty private[scalive] def sync(): Unit = @@ -107,30 +107,38 @@ class SplitVar[I, O, Key]( nextKeys += entryKey memoized.updateWith(entryKey) { // Update matching key - case varAndOutput @ Some(Some((entryVar, _))) => + case varAndOutput @ Some((entryVar, _)) => entryVar.set(input) varAndOutput // Create new item - case Some(None) | None => + case None => val newVar = Var(input) - Some(Some(newVar, project(entryKey, newVar))) + Some(newVar, project(entryKey, newVar)) } ) - memoized.keys.foreach(k => if !nextKeys.contains(k) then memoized.update(k, None)) + memoized.keys.foreach(k => + if !nextKeys.contains(k) then + val _ = memoized.remove(k) + ) - private[scalive] def render(trackUpdates: Boolean): List[(Key, Option[O])] = - memoized.collect { - case (k, Some(entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output)) - case (k, None) => (k, None) - }.toList + private[scalive] def render(trackUpdates: Boolean) + : Option[(changeList: List[(Key, Option[O])], keysCount: Int)] = + if parent.changed || !trackUpdates then + Some( + ( + memoized.collect { + case (k, (entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output)) + }.toList, + memoized.size + ) + ) + else None private[scalive] def setUnchanged(): Unit = parent.setUnchanged() - // Remove previously deleted - memoized.filterInPlace((_, v) => v.nonEmpty) // Usefull to call setUnchanged when the output is an HtmlElement as only the caller can know the type private[scalive] def callOnEveryChild(f: O => Unit): Unit = - memoized.values.foreach(_.foreach((_, output) => f(output))) + memoized.values.foreach((_, output) => f(output)) end SplitVar diff --git a/core/src/scalive/HtmlBuilder.scala b/core/src/scalive/HtmlBuilder.scala index b04d077..7a8251d 100644 --- a/core/src/scalive/HtmlBuilder.scala +++ b/core/src/scalive/HtmlBuilder.scala @@ -20,11 +20,11 @@ object HtmlBuilder: for i <- dynamic.indices do strw.write(static(i)) dynamic(i) match - case Attr.Dyn(attr, value) => - strw.write(value.render(false).map(attr.codec.encode).getOrElse("")) - case Attr.DynValueAsPresence(attr, value) => + case Attr.Dyn(name, value, isJson) => + strw.write(value.render(false).getOrElse("")) + case Attr.DynValueAsPresence(name, value) => strw.write( - value.render(false).map(if _ then s" ${attr.name}" else "").getOrElse("") + value.render(false).map(if _ then s" $name" else "").getOrElse("") ) case Content.Tag(el) => build(el.static, el.dynamicMods, strw) case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse("")) @@ -33,8 +33,8 @@ object HtmlBuilder: dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw))) case Content.DynElementColl(dyn) => ??? case Content.DynSplit(splitVar) => - val entries = splitVar.render(false) - val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static } + val (entries, _) = splitVar.render(false).getOrElse(List.empty -> 0) + val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static } entries.foreach { case (_, Some(entryEl)) => build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw) diff --git a/core/src/scalive/HtmlElement.scala b/core/src/scalive/HtmlElement.scala index c09de6c..d63e423 100644 --- a/core/src/scalive/HtmlElement.scala +++ b/core/src/scalive/HtmlElement.scala @@ -1,9 +1,10 @@ package scalive -import scalive.codecs.BooleanAsAttrPresenceCodec -import scalive.codecs.Codec import scalive.Mod.Attr import scalive.Mod.Content +import scalive.codecs.BooleanAsAttrPresenceCodec +import scalive.codecs.Codec +import zio.json.* class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): def static: Seq[String] = StaticBuilder.build(this) @@ -36,18 +37,26 @@ class HtmlAttr[V](val name: String, val codec: Codec[V, String]): def :=(value: V): Mod.Attr = if isBooleanAsAttrPresence then Mod.Attr.StaticValueAsPresence( - this.asInstanceOf[HtmlAttr[Boolean]], + name, value.asInstanceOf[Boolean] ) - else Mod.Attr.Static(this, codec.encode(value)) + else Mod.Attr.Static(name, codec.encode(value)) def :=(value: Dyn[V]): Mod.Attr = if isBooleanAsAttrPresence then Mod.Attr.DynValueAsPresence( - this.asInstanceOf[HtmlAttr[Boolean]], + name, value.asInstanceOf[Dyn[Boolean]] ) - else Mod.Attr.Dyn(this, value) + else Mod.Attr.Dyn(name, value(codec.encode)) + +class HtmlAttrJsonValue(val name: String): + + def :=[V: JsonCodec](value: V): Mod.Attr = + Mod.Attr.Static(name, value.toJson, isJson = true) + + def :=[V: JsonCodec](value: Dyn[V]): Mod.Attr = + Mod.Attr.Dyn(name, value(_.toJson), isJson = true) sealed trait Mod sealed trait StaticMod extends Mod @@ -55,12 +64,12 @@ sealed trait DynamicMod extends Mod object Mod: enum Attr extends Mod: - case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod - case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod - case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod - case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean]) + case Static(name: String, value: String, isJson: Boolean = false) extends Attr with StaticMod + case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod + case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false) extends Attr with DynamicMod + case DynValueAsPresence(name: String, value: scalive.Dyn[Boolean]) extends Attr with DynamicMod enum Content extends Mod: case Text(text: String) extends Content with StaticMod @@ -75,14 +84,14 @@ object Mod: extension (mod: Mod) private[scalive] def setAllUnchanged(): Unit = mod match - case Attr.Static(_, _) => () - case Attr.StaticValueAsPresence(_, _) => () - case Attr.Dyn(_, value) => value.setUnchanged() - case Attr.DynValueAsPresence(attr, value) => value.setUnchanged() - case Content.Text(text) => () - case Content.Tag(el) => el.setAllUnchanged() - case Content.DynText(dyn) => dyn.setUnchanged() - case Content.DynElement(dyn) => + case Attr.Static(_, _, _) => () + case Attr.StaticValueAsPresence(_, _) => () + case Attr.Dyn(_, value, _) => value.setUnchanged() + case Attr.DynValueAsPresence(_, value) => value.setUnchanged() + case Content.Text(text) => () + case Content.Tag(el) => el.setAllUnchanged() + case Content.DynText(dyn) => dyn.setUnchanged() + case Content.DynElement(dyn) => dyn.setUnchanged() dyn.callOnEveryChild(_.setAllUnchanged()) case Content.DynOptionElement(dyn) => @@ -97,14 +106,14 @@ extension (mod: Mod) private[scalive] def syncAll(): Unit = mod match - case Attr.Static(_, _) => () - case Attr.StaticValueAsPresence(_, _) => () - case Attr.Dyn(_, value) => value.sync() - case Attr.DynValueAsPresence(attr, value) => value.sync() - case Content.Text(text) => () - case Content.Tag(el) => el.syncAll() - case Content.DynText(dyn) => dyn.sync() - case Content.DynElement(dyn) => + case Attr.Static(_, _, _) => () + case Attr.StaticValueAsPresence(_, _) => () + case Attr.Dyn(_, value, _) => value.sync() + case Attr.DynValueAsPresence(_, value) => value.sync() + case Content.Text(text) => () + case Content.Tag(el) => el.syncAll() + case Content.DynText(dyn) => dyn.sync() + case Content.DynElement(dyn) => dyn.sync() dyn.callOnEveryChild(_.syncAll()) case Content.DynOptionElement(dyn) => diff --git a/core/src/scalive/LiveView.scala b/core/src/scalive/LiveView.scala index 13b6e97..d646d6a 100644 --- a/core/src/scalive/LiveView.scala +++ b/core/src/scalive/LiveView.scala @@ -1,5 +1,6 @@ package scalive -trait LiveView[Cmd]: - def handleCommand(cmd: Cmd): Unit +trait LiveView[ClientEvt, ServerEvent]: + def handleClientEvent(evt: ClientEvt): Unit = () + def handleServerEvent(evt: ServerEvent): Unit = () val el: HtmlElement diff --git a/core/src/scalive/Scalive.scala b/core/src/scalive/Scalive.scala index 59584a6..2838d0e 100644 --- a/core/src/scalive/Scalive.scala +++ b/core/src/scalive/Scalive.scala @@ -1,3 +1,4 @@ +import scalive.codecs.StringAsIsCodec import scalive.defs.attrs.HtmlAttrs import scalive.defs.complex.ComplexHtmlKeys import scalive.defs.tags.HtmlTags @@ -6,6 +7,18 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: lazy val defer = htmlAttr("defer", codecs.BooleanAsOnOffStringCodec) + object phx: + private def phxAttr(suffix: String): HtmlAttr[String] = + new HtmlAttr(s"phx-$suffix", StringAsIsCodec) + private def phxAttrJson(suffix: String): HtmlAttrJsonValue = + new HtmlAttrJsonValue(s"phx-$suffix") + private def dataPhxAttr(suffix: String): HtmlAttr[String] = + new HtmlAttr(s"data-phx-$suffix", StringAsIsCodec) + + private[scalive] lazy val session = dataPhxAttr("session") + lazy val click = phxAttrJson("click") + def value(key: String) = phxAttr(s"value-$key") + implicit def stringToMod(v: String): Mod = Mod.Content.Text(v) implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el) implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d) diff --git a/core/src/scalive/Socket.scala b/core/src/scalive/Socket.scala index 6b66fe4..6a29e4f 100644 --- a/core/src/scalive/Socket.scala +++ b/core/src/scalive/Socket.scala @@ -2,28 +2,23 @@ package scalive import zio.json.* -import java.util.Base64 -import scala.util.Random - -final case class Socket[Cmd](lv: LiveView[Cmd]): +final case class Socket[CliEvt: JsonCodec, SrvEvt]( + id: String, + token: String, + lv: LiveView[CliEvt, SrvEvt]): + val clientEventCodec = JsonCodec[CliEvt] private var clientInitialized = false - val id: String = - s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(8))}" - private val token = Token.sign("secret", id, "") lv.el.syncAll() - def receiveCommand(cmd: Cmd): Unit = - lv.handleCommand(cmd) - def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String = lv.el.syncAll() HtmlBuilder.build( rootLayout( div( - idAttr := id, - dataAttr("phx-session") := token, + idAttr := id, + phx.session := token, lv.el ) ) diff --git a/core/src/scalive/StaticBuilder.scala b/core/src/scalive/StaticBuilder.scala index b3d22c2..2db1bf0 100644 --- a/core/src/scalive/StaticBuilder.scala +++ b/core/src/scalive/StaticBuilder.scala @@ -12,10 +12,14 @@ object StaticBuilder: private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = val attrs = el.attrMods.flatMap { - case Attr.Static(attr, value) => List(Some(s""" ${attr.name}="$value"""")) - case Attr.StaticValueAsPresence(attr, value) => List(Some(s" ${attr.name}")) - case Attr.Dyn(attr, value) => List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)) - case Attr.DynValueAsPresence(attr, value) => List(Some(""), None, Some("")) + case Attr.Static(name, value, isJson) => + if isJson then List(Some(s" $name='$value'")) + else List(Some(s""" $name="$value"""")) + case Attr.StaticValueAsPresence(name, value) => List(Some(s" $name")) + case Attr.Dyn(name, value, isJson) => + if isJson then List(Some(s" $name='"), None, Some("'")) + else List(Some(s""" $name=""""), None, Some('"'.toString)) + case Attr.DynValueAsPresence(_, value) => List(Some(""), None, Some("")) } val children = el.contentMods.flatMap { case Content.Text(text) => List(Some(text)) diff --git a/core/src/scalive/Token.scala b/core/src/scalive/Token.scala index fe2db2b..5f0abd4 100644 --- a/core/src/scalive/Token.scala +++ b/core/src/scalive/Token.scala @@ -35,10 +35,10 @@ object Token: mac.doFinal(value) private def base64Encode(value: Array[Byte]): String = - Base64.getEncoder().encodeToString(value) + Base64.getUrlEncoder().withoutPadding().encodeToString(value) private def base64Decode(value: String): Array[Byte] = - Base64.getDecoder().decode(value) + Base64.getUrlDecoder().decode(value) def verify[T: JsonCodec](secret: String, token: String, maxAge: Duration) : Either[String, (liveViewId: String, payload: T)] = diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index 5f38245..2036b18 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -14,7 +14,7 @@ object LiveViewSpec extends TestSuite: cls: String = "text-sm", items: List[NestedModel] = List.empty) final case class NestedModel(name: String, age: Int) - final case class UpdateCmd(f: TestModel => TestModel) + final case class UpdateEvent(f: TestModel => TestModel) def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) = el.syncAll() @@ -27,9 +27,8 @@ object LiveViewSpec extends TestSuite: test("Static only") { val lv = - new LiveView[Unit]: - val el = div("Static string") - def handleCommand(cmd: Unit): Unit = () + new LiveView[String, Unit]: + val el = div("Static string") lv.el.syncAll() test("init") { @@ -48,14 +47,14 @@ object LiveViewSpec extends TestSuite: test("Dynamic string") { val lv = - new LiveView[UpdateCmd]: + new LiveView[UpdateEvent, Nothing]: val model = Var(TestModel()) val el = div( h1(model(_.title)), p(model(_.otherString)) ) - def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + override def handleClientEvent(evt: UpdateEvent): Unit = model.update(evt.f) lv.el.syncAll() lv.el.setAllUnchanged() @@ -76,19 +75,19 @@ object LiveViewSpec extends TestSuite: assertEqualsDiff(lv.el, emptyDiff) } test("diff with update") { - lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated"))) assertEqualsDiff( lv.el, Json.Obj("0" -> Json.Str("title updated")) ) } test("diff with update and no change") { - lv.handleCommand(UpdateCmd(_.copy(title = "title value"))) + lv.handleClientEvent(UpdateEvent(_.copy(title = "title value"))) assertEqualsDiff(lv.el, emptyDiff) } test("diff with update in multiple commands") { - lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) - lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(otherString = "other string updated"))) assertEqualsDiff( lv.el, Json @@ -102,11 +101,11 @@ object LiveViewSpec extends TestSuite: test("Dynamic attribute") { val lv = - new LiveView[UpdateCmd]: + new LiveView[UpdateEvent, Nothing]: val model = Var(TestModel()) val el = div(cls := model(_.cls)) - def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + override def handleClientEvent(evt: UpdateEvent): Unit = model.update(evt.f) lv.el.syncAll() lv.el.setAllUnchanged() @@ -127,7 +126,7 @@ object LiveViewSpec extends TestSuite: assertEqualsDiff(lv.el, emptyDiff) } test("diff with update") { - lv.handleCommand(UpdateCmd(_.copy(cls = "text-md"))) + lv.handleClientEvent(UpdateEvent(_.copy(cls = "text-md"))) assertEqualsDiff( lv.el, Json.Obj("0" -> Json.Str("text-md")) @@ -137,7 +136,7 @@ object LiveViewSpec extends TestSuite: test("when mod") { val lv = - new LiveView[UpdateCmd]: + new LiveView[UpdateEvent, Nothing]: val model = Var(TestModel()) val el = div( @@ -145,7 +144,7 @@ object LiveViewSpec extends TestSuite: div("static string", model(_.nestedTitle)) ) ) - def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + override def handleClientEvent(evt: UpdateEvent): Unit = model.update(evt.f) lv.el.syncAll() lv.el.setAllUnchanged() @@ -165,11 +164,11 @@ object LiveViewSpec extends TestSuite: assertEqualsDiff(lv.el, emptyDiff) } test("diff with unrelated update") { - lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated"))) assertEqualsDiff(lv.el, emptyDiff) } test("diff when true and nested update") { - lv.handleCommand(UpdateCmd(_.copy(bool = true))) + lv.handleClientEvent(UpdateEvent(_.copy(bool = true))) assertEqualsDiff( lv.el, Json.Obj( @@ -184,10 +183,10 @@ object LiveViewSpec extends TestSuite: ) } test("diff when nested change") { - lv.handleCommand(UpdateCmd(_.copy(bool = true))) + lv.handleClientEvent(UpdateEvent(_.copy(bool = true))) lv.el.syncAll() lv.el.setAllUnchanged() - lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(bool = true, nestedTitle = "nested title updated"))) assertEqualsDiff( lv.el, Json.Obj( @@ -210,7 +209,7 @@ object LiveViewSpec extends TestSuite: ) ) val lv = - new LiveView[UpdateCmd]: + new LiveView[UpdateEvent, Nothing]: val model = Var(initModel) val el = div( @@ -225,7 +224,7 @@ object LiveViewSpec extends TestSuite: ) ) ) - def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + override def handleClientEvent(evt: UpdateEvent): Unit = model.update(evt.f) lv.el.syncAll() lv.el.setAllUnchanged() @@ -242,7 +241,7 @@ object LiveViewSpec extends TestSuite: Json.Str(" Age: "), Json.Str("") ), - "d" -> Json.Obj( + "k" -> Json.Obj( "0" -> Json.Obj( "0" -> Json.Str("a"), "1" -> Json.Str("10") @@ -254,7 +253,8 @@ object LiveViewSpec extends TestSuite: "2" -> Json.Obj( "0" -> Json.Str("c"), "1" -> Json.Str("20") - ) + ), + "kc" -> Json.Num(3) ) ) ), @@ -265,12 +265,12 @@ object LiveViewSpec extends TestSuite: assertEqualsDiff(lv.el, emptyDiff) } test("diff with unrelated update") { - lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated"))) assertEqualsDiff(lv.el, emptyDiff) } test("diff with item changed") { - lv.handleCommand( - UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99)))) + lv.handleClientEvent( + UpdateEvent(_.copy(items = initModel.items.updated(2, NestedModel("c", 99)))) ) assertEqualsDiff( lv.el, @@ -278,18 +278,19 @@ object LiveViewSpec extends TestSuite: "0" -> Json .Obj( - "d" -> Json.Obj( + "k" -> Json.Obj( "2" -> Json.Obj( "1" -> Json.Str("99") - ) + ), + "kc" -> Json.Num(3) ) ) ) ) } test("diff with item added") { - lv.handleCommand( - UpdateCmd( + lv.handleClientEvent( + UpdateEvent( _.copy(items = initModel.items.appended(NestedModel("d", 35))) ) ) @@ -299,19 +300,20 @@ object LiveViewSpec extends TestSuite: "0" -> Json .Obj( - "d" -> Json.Obj( + "k" -> Json.Obj( "3" -> Json.Obj( "0" -> Json.Str("d"), "1" -> Json.Str("35") - ) + ), + "kc" -> Json.Num(4) ) ) ) ) } test("diff with first item removed") { - lv.handleCommand( - UpdateCmd( + lv.handleClientEvent( + UpdateEvent( _.copy(items = initModel.items.tail) ) ) @@ -321,7 +323,7 @@ object LiveViewSpec extends TestSuite: "0" -> Json .Obj( - "d" -> Json.Obj( + "k" -> Json.Obj( "0" -> Json.Obj( "0" -> Json.Str("b"), "1" -> Json.Str("15") @@ -330,24 +332,22 @@ object LiveViewSpec extends TestSuite: "0" -> Json.Str("c"), "1" -> Json.Str("20") ), - "2" -> Json.Bool(false) + "kc" -> Json.Num(2) ) ) ) ) } test("diff all removed") { - lv.handleCommand(UpdateCmd(_.copy(items = List.empty))) + lv.handleClientEvent(UpdateEvent(_.copy(items = List.empty))) assertEqualsDiff( lv.el, Json.Obj( "0" -> Json .Obj( - "d" -> Json.Obj( - "0" -> Json.Bool(false), - "1" -> Json.Bool(false), - "2" -> Json.Bool(false) + "k" -> Json.Obj( + "kc" -> Json.Num(0) ) ) ) diff --git a/example/src/ExampleLiveView.scala b/example/src/ExampleLiveView.scala index 740e53f..4c84290 100644 --- a/example/src/ExampleLiveView.scala +++ b/example/src/ExampleLiveView.scala @@ -1,9 +1,12 @@ +import ExampleLiveView.Evt +import monocle.syntax.all.* import scalive.* +import zio.json.* final case class ExampleModel(elems: List[NestedModel], cls: String = "text-xs") final case class NestedModel(name: String, age: Int) -class ExampleLiveView(someParam: String) extends LiveView[Nothing]: +class ExampleLiveView(someParam: String) extends LiveView[Evt, String]: val model = Var( ExampleModel( @@ -15,11 +18,15 @@ class ExampleLiveView(someParam: String) extends LiveView[Nothing]: ) ) - def handleCommand(cmd: Nothing): Unit = () + override def handleClientEvent(evt: Evt): Unit = + evt match + case Evt.IncAge(value) => + model.update(_.focus(_.elems.index(2).age).modify(_ + value)) val el = div( h1(someParam), + h2(model(_.cls)), idAttr := "42", cls := model(_.cls), ul( @@ -31,6 +38,14 @@ class ExampleLiveView(someParam: String) extends LiveView[Nothing]: elem(_.age.toString) ) ) + ), + button( + phx.click := Evt.IncAge(1), + "Inc age" ) ) end ExampleLiveView + +object ExampleLiveView: + enum Evt derives JsonCodec: + case IncAge(value: Int) diff --git a/zio/src/scalive/LiveRouter.scala b/zio/src/scalive/LiveRouter.scala index 6cc85f3..f912f6f 100644 --- a/zio/src/scalive/LiveRouter.scala +++ b/zio/src/scalive/LiveRouter.scala @@ -2,6 +2,7 @@ package scalive import scalive.SocketMessage.LiveResponse import scalive.SocketMessage.Payload +import scalive.SocketMessage.Payload.EventType import zio.* import zio.http.* import zio.http.ChannelEvent.Read @@ -10,19 +11,60 @@ import zio.http.template.Html import zio.json.* import zio.json.ast.Json -final case class LiveRoute[A, Cmd]( +import java.util.Base64 +import scala.collection.mutable +import scala.util.Random + +final case class LiveRoute[A, ClientEvt: JsonCodec, ServerEvt]( path: PathCodec[A], - liveviewBuilder: (A, Request) => LiveView[Cmd]): + liveviewBuilder: (A, Request) => LiveView[ClientEvt, ServerEvt]): + val clientEventCodec = JsonCodec[ClientEvt] def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Nothing] = Method.GET / path -> handler { (params: A, req: Request) => - val s = Socket(liveviewBuilder(params, req)) - Response.html(Html.raw(s.renderHtml(rootLayout))) + val lv = liveviewBuilder(params, req) + val id: String = + s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))}" + val token = Token.sign("secret", id, "") + lv.el.syncAll() + Response.html( + Html.raw( + HtmlBuilder.build( + rootLayout( + div( + idAttr := id, + phx.session := token, + lv.el + ) + ) + ) + ) + ) } -class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?]]): +class LiveChannel(): + // TODO not thread safe + private val sockets: mutable.Map[String, Socket[?, ?]] = mutable.Map.empty + + // TODO should check id isn't already present + def join[ClientEvt: JsonCodec](id: String, token: String, lv: LiveView[ClientEvt, ?]): Diff = + val socket = Socket(id, token, lv) + sockets.addOne(id, socket) + socket.diff + + // TODO handle missing id + def event(id: String, value: String): Diff = + val s = sockets(id) + s.lv.handleClientEvent( + value + .fromJson(using s.clientEventCodec.decoder).getOrElse(throw new IllegalArgumentException()) + ) + s.diff + +class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?, ?]]): private val socketApp: WebSocketApp[Any] = + val liveChannel = new LiveChannel() Handler.webSocket { channel => channel .receiveAll { @@ -31,18 +73,17 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo message <- ZIO .fromEither(content.fromJson[SocketMessage]) .mapError(new IllegalArgumentException(_)) - reply <- handleMessage(message) + reply <- handleMessage(message, liveChannel) _ <- channel.send(Read(WebSocketFrame.text(reply.toJson))) yield () case _ => ZIO.unit }.tapErrorCause(ZIO.logErrorCause(_)) } - def handleMessage(message: SocketMessage): Task[SocketMessage] = + private def handleMessage(message: SocketMessage, liveChannel: LiveChannel): Task[SocketMessage] = val reply = message.payload match case Payload.Heartbeat => ZIO.succeed(Payload.Reply("ok", LiveResponse.Empty)) case Payload.Join(url, session, static, sticky) => - // TODO very rough handling ZIO .fromEither(URL.decode(url)).map(url => val req = Request(url = url) @@ -50,11 +91,15 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo .collectFirst { route => val pathParams = route.path.decode(req.path).getOrElse(???) val lv = route.liveviewBuilder(pathParams, req) - val s = Socket(lv) - Payload.Reply("ok", LiveResponse.InitDiff(s.diff)) + val diff = + liveChannel.join(message.topic, session, lv)(using route.clientEventCodec) + Payload.Reply("ok", LiveResponse.InitDiff(diff)) }.getOrElse(???) ) + case Payload.Event(_, event, _) => + val diff = liveChannel.event(message.topic, event) + ZIO.succeed(Payload.Reply("ok", LiveResponse.Diff(diff))) case Payload.Reply(_, _) => ZIO.die(new IllegalArgumentException()) reply.map(SocketMessage(message.joinRef, message.messageRef, message.topic, "phx_reply", _)) @@ -87,6 +132,7 @@ object SocketMessage: val payloadParsed = eventType match case "heartbeat" => Right(Payload.Heartbeat) case "phx_join" => payload.as[Payload.Join] + case "event" => payload.as[Payload.Event] case s => Left(s"Unknown event type : $s") payloadParsed.map( @@ -110,6 +156,7 @@ object SocketMessage: case Payload.Heartbeat => Json.Obj.empty case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) case p: Payload.Reply => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) + case p: Payload.Event => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) ) ) @@ -122,13 +169,29 @@ object SocketMessage: static: Option[String], sticky: Boolean) case Reply(status: String, response: LiveResponse) + case Event(`type`: Payload.EventType, event: String, value: Map[String, String]) object Payload: given JsonCodec[Payload.Join] = JsonCodec.derived given JsonEncoder[Payload.Reply] = JsonEncoder.derived + given JsonCodec[Payload.Event] = JsonCodec.derived + + 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) + case Diff(diff: scalive.Diff) object LiveResponse: given JsonEncoder[LiveResponse] = JsonEncoder[Json].contramap { @@ -138,5 +201,9 @@ object SocketMessage: "liveview_version" -> Json.Str("1.1.8"), "rendered" -> rendered.toJsonAST.getOrElse(throw new IllegalArgumentException()) ) + case Diff(diff) => + Json.Obj( + "diff" -> diff.toJsonAST.getOrElse(throw new IllegalArgumentException()) + ) } end SocketMessage