Support click events

This commit is contained in:
Paul-Henri Froidmont 2025-08-27 02:28:22 +02:00
parent 1bd65fd49c
commit 124239925d
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
16 changed files with 277 additions and 162 deletions

View file

@ -37,6 +37,7 @@ object zio extends ScalaCommon:
object example extends ScalaCommon: object example extends ScalaCommon:
def moduleDeps = Seq(zio) def moduleDeps = Seq(zio)
def mvnDeps = Seq(mvn"dev.optics::monocle-core:3.1.0")
def scaliveBundle = Task { def scaliveBundle = Task {
os.copy( os.copy(

View file

@ -6,13 +6,13 @@ final case class MyModel(
elems: List[Elem] = List.empty) elems: List[Elem] = List.empty)
final case class Elem(name: String, age: Int) final case class Elem(name: String, age: Int)
class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]: class TestView(initialModel: MyModel) extends LiveView[String, TestView.Event]:
import TestView.Cmd.* import TestView.Event.*
private val modelVar = Var[MyModel](initialModel) private val modelVar = Var[MyModel](initialModel)
def handleCommand(cmd: TestView.Cmd): Unit = override def handleServerEvent(e: TestView.Event): Unit =
cmd match e match
case UpdateModel(f) => modelVar.update(f) case UpdateModel(f) => modelVar.update(f)
val el: HtmlElement = val el: HtmlElement =
@ -33,5 +33,5 @@ class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]:
) )
object TestView: object TestView:
enum Cmd: enum Event:
case UpdateModel(f: MyModel => MyModel) case UpdateModel(f: MyModel => MyModel)

View file

@ -1,4 +1,5 @@
import scalive.* import scalive.*
import zio.json.JsonCodec
@main @main
def main = def main =
@ -9,21 +10,21 @@ def main =
Elem("c", 30) Elem("c", 30)
) )
) )
val s = Socket(TestView(initModel)) val s = Socket("", "", TestView(initModel))
println("Init") println("Init")
println(s.renderHtml()) println(s.renderHtml())
s.syncClient s.syncClient
s.syncClient s.syncClient
println("Edit class attribue") println("Edit class attribue")
s.receiveCommand( s.lv.handleServerEvent(
TestView.Cmd.UpdateModel(_.copy(cls = "text-lg")) TestView.Event.UpdateModel(_.copy(cls = "text-lg"))
) )
s.syncClient s.syncClient
println("Edit first and last") println("Edit first and last")
s.receiveCommand( s.lv.handleServerEvent(
TestView.Cmd.UpdateModel( TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("x", 10), Elem("x", 10),
@ -37,8 +38,8 @@ def main =
println(s.renderHtml()) println(s.renderHtml())
println("Add one") println("Add one")
s.receiveCommand( s.lv.handleServerEvent(
TestView.Cmd.UpdateModel( TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("x", 10), Elem("x", 10),
@ -53,8 +54,8 @@ def main =
println(s.renderHtml()) println(s.renderHtml())
println("Remove first") println("Remove first")
s.receiveCommand( s.lv.handleServerEvent(
TestView.Cmd.UpdateModel( TestView.Event.UpdateModel(
_.copy(elems = _.copy(elems =
List( List(
Elem("b", 15), Elem("b", 15),
@ -68,8 +69,8 @@ def main =
println(s.renderHtml()) println(s.renderHtml())
println("Remove all") println("Remove all")
s.receiveCommand( s.lv.handleServerEvent(
TestView.Cmd.UpdateModel( TestView.Event.UpdateModel(
_.copy( _.copy(
cls = "text-lg", cls = "text-lg",
bool = false, bool = false,

View file

@ -10,7 +10,8 @@ enum Diff:
dynamic: Seq[Diff.Dynamic] = Seq.empty) dynamic: Seq[Diff.Dynamic] = Seq.empty)
case Comprehension( case Comprehension(
static: Seq[String] = Seq.empty, 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 Value(value: String)
case Dynamic(key: String, diff: Diff) case Dynamic(key: String, diff: Diff)
case Deleted case Deleted
@ -29,19 +30,17 @@ object Diff:
dynamic.map(d => d.key -> toJson(d.diff)) dynamic.map(d => d.key -> toJson(d.diff))
) )
) )
case Diff.Comprehension(static, entries) => case Diff.Comprehension(static, entries, count) =>
Json.Obj( Json.Obj(
Option Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk) .to(Chunk)
.appendedAll( .appended(
Option.when(entries.nonEmpty)(
"k" -> "k" ->
Json Json
.Obj( .Obj(
entries.map(d => d.key -> toJson(d.diff))* entries.map(d => d.key -> toJson(d.diff))*
).add("kc", Json.Num(entries.length)) ).add("kc", Json.Num(count))
)
) )
) )
case Diff.Value(value) => Json.Str(value) case Diff.Value(value) => Json.Str(value)

View file

@ -23,10 +23,10 @@ object DiffBuilder:
private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] = private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] =
dynamicMods.flatMap { dynamicMods.flatMap {
case Attr.Dyn(attr, value) => case Attr.Dyn(name, value, _) =>
List(value.render(trackUpdates).map(v => Diff.Value(v.toString))) List(value.render(trackUpdates).map(v => Diff.Value(v.toString)))
case Attr.DynValueAsPresence(attr, value) => case Attr.DynValueAsPresence(name, value) =>
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else ""))) List(value.render(trackUpdates).map(v => Diff.Value(if v then s" $name" else "")))
case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates) case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates)
case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_))) case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_)))
case Content.DynElement(dyn) => ??? case Content.DynElement(dyn) => ???
@ -40,9 +40,8 @@ object DiffBuilder:
case None => dyn.currentValue.map(build(_, trackUpdates))) case None => dyn.currentValue.map(build(_, trackUpdates)))
case Content.DynElementColl(dyn) => ??? case Content.DynElementColl(dyn) => ???
case Content.DynSplit(splitVar) => case Content.DynSplit(splitVar) =>
val entries = splitVar.render(trackUpdates) splitVar.render(trackUpdates) match
if entries.isEmpty then List.empty case Some((entries, keysCount)) =>
else
val static = val static =
entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty)
List( List(
@ -53,10 +52,13 @@ object DiffBuilder:
case (key, Some(el)) => case (key, Some(el)) =>
Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates))
case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted) case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted)
} },
count = keysCount
) )
) )
) )
case None => List(None)
} }
end DiffBuilder end DiffBuilder

View file

@ -94,7 +94,7 @@ class SplitVar[I, O, Key](
project: (Key, Dyn[I]) => O): project: (Key, Dyn[I]) => O):
// Deleted elements have value none // 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 mutable.Map.empty
private[scalive] def sync(): Unit = private[scalive] def sync(): Unit =
@ -107,30 +107,38 @@ class SplitVar[I, O, Key](
nextKeys += entryKey nextKeys += entryKey
memoized.updateWith(entryKey) { memoized.updateWith(entryKey) {
// Update matching key // Update matching key
case varAndOutput @ Some(Some((entryVar, _))) => case varAndOutput @ Some((entryVar, _)) =>
entryVar.set(input) entryVar.set(input)
varAndOutput varAndOutput
// Create new item // Create new item
case Some(None) | None => case None =>
val newVar = Var(input) 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])] = private[scalive] def render(trackUpdates: Boolean)
: Option[(changeList: List[(Key, Option[O])], keysCount: Int)] =
if parent.changed || !trackUpdates then
Some(
(
memoized.collect { memoized.collect {
case (k, Some(entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output)) case (k, (entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output))
case (k, None) => (k, None) }.toList,
}.toList memoized.size
)
)
else None
private[scalive] def setUnchanged(): Unit = private[scalive] def setUnchanged(): Unit =
parent.setUnchanged() 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 // 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 = private[scalive] def callOnEveryChild(f: O => Unit): Unit =
memoized.values.foreach(_.foreach((_, output) => f(output))) memoized.values.foreach((_, output) => f(output))
end SplitVar end SplitVar

View file

@ -20,11 +20,11 @@ object HtmlBuilder:
for i <- dynamic.indices do for i <- dynamic.indices do
strw.write(static(i)) strw.write(static(i))
dynamic(i) match dynamic(i) match
case Attr.Dyn(attr, value) => case Attr.Dyn(name, value, isJson) =>
strw.write(value.render(false).map(attr.codec.encode).getOrElse("")) strw.write(value.render(false).getOrElse(""))
case Attr.DynValueAsPresence(attr, value) => case Attr.DynValueAsPresence(name, value) =>
strw.write( 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.Tag(el) => build(el.static, el.dynamicMods, strw)
case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse("")) case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse(""))
@ -33,7 +33,7 @@ object HtmlBuilder:
dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw))) dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw)))
case Content.DynElementColl(dyn) => ??? case Content.DynElementColl(dyn) => ???
case Content.DynSplit(splitVar) => case Content.DynSplit(splitVar) =>
val entries = splitVar.render(false) val (entries, _) = splitVar.render(false).getOrElse(List.empty -> 0)
val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static } val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static }
entries.foreach { entries.foreach {
case (_, Some(entryEl)) => case (_, Some(entryEl)) =>

View file

@ -1,9 +1,10 @@
package scalive package scalive
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
import scalive.Mod.Attr import scalive.Mod.Attr
import scalive.Mod.Content import scalive.Mod.Content
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
import zio.json.*
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
def static: Seq[String] = StaticBuilder.build(this) 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 = def :=(value: V): Mod.Attr =
if isBooleanAsAttrPresence then if isBooleanAsAttrPresence then
Mod.Attr.StaticValueAsPresence( Mod.Attr.StaticValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]], name,
value.asInstanceOf[Boolean] 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 = def :=(value: Dyn[V]): Mod.Attr =
if isBooleanAsAttrPresence then if isBooleanAsAttrPresence then
Mod.Attr.DynValueAsPresence( Mod.Attr.DynValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]], name,
value.asInstanceOf[Dyn[Boolean]] 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 Mod
sealed trait StaticMod extends Mod sealed trait StaticMod extends Mod
@ -55,12 +64,12 @@ sealed trait DynamicMod extends Mod
object Mod: object Mod:
enum Attr extends Mod: enum Attr extends Mod:
case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod case Static(name: String, value: String, isJson: Boolean = false) extends Attr with StaticMod
case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod
case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false)
case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean])
extends Attr extends Attr
with DynamicMod with DynamicMod
case DynValueAsPresence(name: String, value: scalive.Dyn[Boolean]) extends Attr with DynamicMod
enum Content extends Mod: enum Content extends Mod:
case Text(text: String) extends Content with StaticMod case Text(text: String) extends Content with StaticMod
@ -75,10 +84,10 @@ object Mod:
extension (mod: Mod) extension (mod: Mod)
private[scalive] def setAllUnchanged(): Unit = private[scalive] def setAllUnchanged(): Unit =
mod match mod match
case Attr.Static(_, _) => () case Attr.Static(_, _, _) => ()
case Attr.StaticValueAsPresence(_, _) => () case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value) => value.setUnchanged() case Attr.Dyn(_, value, _) => value.setUnchanged()
case Attr.DynValueAsPresence(attr, value) => value.setUnchanged() case Attr.DynValueAsPresence(_, value) => value.setUnchanged()
case Content.Text(text) => () case Content.Text(text) => ()
case Content.Tag(el) => el.setAllUnchanged() case Content.Tag(el) => el.setAllUnchanged()
case Content.DynText(dyn) => dyn.setUnchanged() case Content.DynText(dyn) => dyn.setUnchanged()
@ -97,10 +106,10 @@ extension (mod: Mod)
private[scalive] def syncAll(): Unit = private[scalive] def syncAll(): Unit =
mod match mod match
case Attr.Static(_, _) => () case Attr.Static(_, _, _) => ()
case Attr.StaticValueAsPresence(_, _) => () case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value) => value.sync() case Attr.Dyn(_, value, _) => value.sync()
case Attr.DynValueAsPresence(attr, value) => value.sync() case Attr.DynValueAsPresence(_, value) => value.sync()
case Content.Text(text) => () case Content.Text(text) => ()
case Content.Tag(el) => el.syncAll() case Content.Tag(el) => el.syncAll()
case Content.DynText(dyn) => dyn.sync() case Content.DynText(dyn) => dyn.sync()

View file

@ -1,5 +1,6 @@
package scalive package scalive
trait LiveView[Cmd]: trait LiveView[ClientEvt, ServerEvent]:
def handleCommand(cmd: Cmd): Unit def handleClientEvent(evt: ClientEvt): Unit = ()
def handleServerEvent(evt: ServerEvent): Unit = ()
val el: HtmlElement val el: HtmlElement

View file

@ -1,3 +1,4 @@
import scalive.codecs.StringAsIsCodec
import scalive.defs.attrs.HtmlAttrs import scalive.defs.attrs.HtmlAttrs
import scalive.defs.complex.ComplexHtmlKeys import scalive.defs.complex.ComplexHtmlKeys
import scalive.defs.tags.HtmlTags 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) 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 stringToMod(v: String): Mod = Mod.Content.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el) implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d) implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d)

View file

@ -2,28 +2,23 @@ package scalive
import zio.json.* import zio.json.*
import java.util.Base64 final case class Socket[CliEvt: JsonCodec, SrvEvt](
import scala.util.Random id: String,
token: String,
final case class Socket[Cmd](lv: LiveView[Cmd]): lv: LiveView[CliEvt, SrvEvt]):
val clientEventCodec = JsonCodec[CliEvt]
private var clientInitialized = false 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() lv.el.syncAll()
def receiveCommand(cmd: Cmd): Unit =
lv.handleCommand(cmd)
def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String = def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String =
lv.el.syncAll() lv.el.syncAll()
HtmlBuilder.build( HtmlBuilder.build(
rootLayout( rootLayout(
div( div(
idAttr := id, idAttr := id,
dataAttr("phx-session") := token, phx.session := token,
lv.el lv.el
) )
) )

View file

@ -12,10 +12,14 @@ object StaticBuilder:
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
val attrs = el.attrMods.flatMap { val attrs = el.attrMods.flatMap {
case Attr.Static(attr, value) => List(Some(s""" ${attr.name}="$value"""")) case Attr.Static(name, value, isJson) =>
case Attr.StaticValueAsPresence(attr, value) => List(Some(s" ${attr.name}")) if isJson then List(Some(s" $name='$value'"))
case Attr.Dyn(attr, value) => List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)) else List(Some(s""" $name="$value""""))
case Attr.DynValueAsPresence(attr, value) => List(Some(""), None, Some("")) 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 { val children = el.contentMods.flatMap {
case Content.Text(text) => List(Some(text)) case Content.Text(text) => List(Some(text))

View file

@ -35,10 +35,10 @@ object Token:
mac.doFinal(value) mac.doFinal(value)
private def base64Encode(value: Array[Byte]): String = private def base64Encode(value: Array[Byte]): String =
Base64.getEncoder().encodeToString(value) Base64.getUrlEncoder().withoutPadding().encodeToString(value)
private def base64Decode(value: String): Array[Byte] = 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) def verify[T: JsonCodec](secret: String, token: String, maxAge: Duration)
: Either[String, (liveViewId: String, payload: T)] = : Either[String, (liveViewId: String, payload: T)] =

View file

@ -14,7 +14,7 @@ object LiveViewSpec extends TestSuite:
cls: String = "text-sm", cls: String = "text-sm",
items: List[NestedModel] = List.empty) items: List[NestedModel] = List.empty)
final case class NestedModel(name: String, age: Int) 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) = def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) =
el.syncAll() el.syncAll()
@ -27,9 +27,8 @@ object LiveViewSpec extends TestSuite:
test("Static only") { test("Static only") {
val lv = val lv =
new LiveView[Unit]: new LiveView[String, Unit]:
val el = div("Static string") val el = div("Static string")
def handleCommand(cmd: Unit): Unit = ()
lv.el.syncAll() lv.el.syncAll()
test("init") { test("init") {
@ -48,14 +47,14 @@ object LiveViewSpec extends TestSuite:
test("Dynamic string") { test("Dynamic string") {
val lv = val lv =
new LiveView[UpdateCmd]: new LiveView[UpdateEvent, Nothing]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div( div(
h1(model(_.title)), h1(model(_.title)),
p(model(_.otherString)) 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.syncAll()
lv.el.setAllUnchanged() lv.el.setAllUnchanged()
@ -76,19 +75,19 @@ object LiveViewSpec extends TestSuite:
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with update") { test("diff with update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated")))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json.Obj("0" -> Json.Str("title updated")) Json.Obj("0" -> Json.Str("title updated"))
) )
} }
test("diff with update and no change") { 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) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with update in multiple commands") { test("diff with update in multiple commands") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated")))
lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated"))) lv.handleClientEvent(UpdateEvent(_.copy(otherString = "other string updated")))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json Json
@ -102,11 +101,11 @@ object LiveViewSpec extends TestSuite:
test("Dynamic attribute") { test("Dynamic attribute") {
val lv = val lv =
new LiveView[UpdateCmd]: new LiveView[UpdateEvent, Nothing]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div(cls := model(_.cls)) 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.syncAll()
lv.el.setAllUnchanged() lv.el.setAllUnchanged()
@ -127,7 +126,7 @@ object LiveViewSpec extends TestSuite:
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with update") { test("diff with update") {
lv.handleCommand(UpdateCmd(_.copy(cls = "text-md"))) lv.handleClientEvent(UpdateEvent(_.copy(cls = "text-md")))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json.Obj("0" -> Json.Str("text-md")) Json.Obj("0" -> Json.Str("text-md"))
@ -137,7 +136,7 @@ object LiveViewSpec extends TestSuite:
test("when mod") { test("when mod") {
val lv = val lv =
new LiveView[UpdateCmd]: new LiveView[UpdateEvent, Nothing]:
val model = Var(TestModel()) val model = Var(TestModel())
val el = val el =
div( div(
@ -145,7 +144,7 @@ object LiveViewSpec extends TestSuite:
div("static string", model(_.nestedTitle)) 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.syncAll()
lv.el.setAllUnchanged() lv.el.setAllUnchanged()
@ -165,11 +164,11 @@ object LiveViewSpec extends TestSuite:
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated")))
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff when true and nested update") { test("diff when true and nested update") {
lv.handleCommand(UpdateCmd(_.copy(bool = true))) lv.handleClientEvent(UpdateEvent(_.copy(bool = true)))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json.Obj( Json.Obj(
@ -184,10 +183,10 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff when nested change") { test("diff when nested change") {
lv.handleCommand(UpdateCmd(_.copy(bool = true))) lv.handleClientEvent(UpdateEvent(_.copy(bool = true)))
lv.el.syncAll() lv.el.syncAll()
lv.el.setAllUnchanged() lv.el.setAllUnchanged()
lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated"))) lv.handleClientEvent(UpdateEvent(_.copy(bool = true, nestedTitle = "nested title updated")))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json.Obj( Json.Obj(
@ -210,7 +209,7 @@ object LiveViewSpec extends TestSuite:
) )
) )
val lv = val lv =
new LiveView[UpdateCmd]: new LiveView[UpdateEvent, Nothing]:
val model = Var(initModel) val model = Var(initModel)
val el = val el =
div( 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.syncAll()
lv.el.setAllUnchanged() lv.el.setAllUnchanged()
@ -242,7 +241,7 @@ object LiveViewSpec extends TestSuite:
Json.Str(" Age: "), Json.Str(" Age: "),
Json.Str("</li>") Json.Str("</li>")
), ),
"d" -> Json.Obj( "k" -> Json.Obj(
"0" -> Json.Obj( "0" -> Json.Obj(
"0" -> Json.Str("a"), "0" -> Json.Str("a"),
"1" -> Json.Str("10") "1" -> Json.Str("10")
@ -254,7 +253,8 @@ object LiveViewSpec extends TestSuite:
"2" -> Json.Obj( "2" -> Json.Obj(
"0" -> Json.Str("c"), "0" -> Json.Str("c"),
"1" -> Json.Str("20") "1" -> Json.Str("20")
) ),
"kc" -> Json.Num(3)
) )
) )
), ),
@ -265,12 +265,12 @@ object LiveViewSpec extends TestSuite:
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) lv.handleClientEvent(UpdateEvent(_.copy(title = "title updated")))
assertEqualsDiff(lv.el, emptyDiff) assertEqualsDiff(lv.el, emptyDiff)
} }
test("diff with item changed") { test("diff with item changed") {
lv.handleCommand( lv.handleClientEvent(
UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99)))) UpdateEvent(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
) )
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
@ -278,18 +278,19 @@ object LiveViewSpec extends TestSuite:
"0" -> "0" ->
Json Json
.Obj( .Obj(
"d" -> Json.Obj( "k" -> Json.Obj(
"2" -> Json.Obj( "2" -> Json.Obj(
"1" -> Json.Str("99") "1" -> Json.Str("99")
) ),
"kc" -> Json.Num(3)
) )
) )
) )
) )
} }
test("diff with item added") { test("diff with item added") {
lv.handleCommand( lv.handleClientEvent(
UpdateCmd( UpdateEvent(
_.copy(items = initModel.items.appended(NestedModel("d", 35))) _.copy(items = initModel.items.appended(NestedModel("d", 35)))
) )
) )
@ -299,19 +300,20 @@ object LiveViewSpec extends TestSuite:
"0" -> "0" ->
Json Json
.Obj( .Obj(
"d" -> Json.Obj( "k" -> Json.Obj(
"3" -> Json.Obj( "3" -> Json.Obj(
"0" -> Json.Str("d"), "0" -> Json.Str("d"),
"1" -> Json.Str("35") "1" -> Json.Str("35")
) ),
"kc" -> Json.Num(4)
) )
) )
) )
) )
} }
test("diff with first item removed") { test("diff with first item removed") {
lv.handleCommand( lv.handleClientEvent(
UpdateCmd( UpdateEvent(
_.copy(items = initModel.items.tail) _.copy(items = initModel.items.tail)
) )
) )
@ -321,7 +323,7 @@ object LiveViewSpec extends TestSuite:
"0" -> "0" ->
Json Json
.Obj( .Obj(
"d" -> Json.Obj( "k" -> Json.Obj(
"0" -> Json.Obj( "0" -> Json.Obj(
"0" -> Json.Str("b"), "0" -> Json.Str("b"),
"1" -> Json.Str("15") "1" -> Json.Str("15")
@ -330,24 +332,22 @@ object LiveViewSpec extends TestSuite:
"0" -> Json.Str("c"), "0" -> Json.Str("c"),
"1" -> Json.Str("20") "1" -> Json.Str("20")
), ),
"2" -> Json.Bool(false) "kc" -> Json.Num(2)
) )
) )
) )
) )
} }
test("diff all removed") { test("diff all removed") {
lv.handleCommand(UpdateCmd(_.copy(items = List.empty))) lv.handleClientEvent(UpdateEvent(_.copy(items = List.empty)))
assertEqualsDiff( assertEqualsDiff(
lv.el, lv.el,
Json.Obj( Json.Obj(
"0" -> "0" ->
Json Json
.Obj( .Obj(
"d" -> Json.Obj( "k" -> Json.Obj(
"0" -> Json.Bool(false), "kc" -> Json.Num(0)
"1" -> Json.Bool(false),
"2" -> Json.Bool(false)
) )
) )
) )

View file

@ -1,9 +1,12 @@
import ExampleLiveView.Evt
import monocle.syntax.all.*
import scalive.* import scalive.*
import zio.json.*
final case class ExampleModel(elems: List[NestedModel], cls: String = "text-xs") final case class ExampleModel(elems: List[NestedModel], cls: String = "text-xs")
final case class NestedModel(name: String, age: Int) 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( val model = Var(
ExampleModel( 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 = val el =
div( div(
h1(someParam), h1(someParam),
h2(model(_.cls)),
idAttr := "42", idAttr := "42",
cls := model(_.cls), cls := model(_.cls),
ul( ul(
@ -31,6 +38,14 @@ class ExampleLiveView(someParam: String) extends LiveView[Nothing]:
elem(_.age.toString) elem(_.age.toString)
) )
) )
),
button(
phx.click := Evt.IncAge(1),
"Inc age"
) )
) )
end ExampleLiveView end ExampleLiveView
object ExampleLiveView:
enum Evt derives JsonCodec:
case IncAge(value: Int)

View file

@ -2,6 +2,7 @@ package scalive
import scalive.SocketMessage.LiveResponse import scalive.SocketMessage.LiveResponse
import scalive.SocketMessage.Payload import scalive.SocketMessage.Payload
import scalive.SocketMessage.Payload.EventType
import zio.* import zio.*
import zio.http.* import zio.http.*
import zio.http.ChannelEvent.Read import zio.http.ChannelEvent.Read
@ -10,19 +11,60 @@ import zio.http.template.Html
import zio.json.* import zio.json.*
import zio.json.ast.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], 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] = def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Nothing] =
Method.GET / path -> handler { (params: A, req: Request) => Method.GET / path -> handler { (params: A, req: Request) =>
val s = Socket(liveviewBuilder(params, req)) val lv = liveviewBuilder(params, req)
Response.html(Html.raw(s.renderHtml(rootLayout))) 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] = private val socketApp: WebSocketApp[Any] =
val liveChannel = new LiveChannel()
Handler.webSocket { channel => Handler.webSocket { channel =>
channel channel
.receiveAll { .receiveAll {
@ -31,18 +73,17 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
message <- ZIO message <- ZIO
.fromEither(content.fromJson[SocketMessage]) .fromEither(content.fromJson[SocketMessage])
.mapError(new IllegalArgumentException(_)) .mapError(new IllegalArgumentException(_))
reply <- handleMessage(message) reply <- handleMessage(message, liveChannel)
_ <- channel.send(Read(WebSocketFrame.text(reply.toJson))) _ <- channel.send(Read(WebSocketFrame.text(reply.toJson)))
yield () yield ()
case _ => ZIO.unit case _ => ZIO.unit
}.tapErrorCause(ZIO.logErrorCause(_)) }.tapErrorCause(ZIO.logErrorCause(_))
} }
def handleMessage(message: SocketMessage): Task[SocketMessage] = private def handleMessage(message: SocketMessage, liveChannel: LiveChannel): Task[SocketMessage] =
val reply = message.payload match val reply = message.payload match
case Payload.Heartbeat => ZIO.succeed(Payload.Reply("ok", LiveResponse.Empty)) case Payload.Heartbeat => ZIO.succeed(Payload.Reply("ok", LiveResponse.Empty))
case Payload.Join(url, session, static, sticky) => case Payload.Join(url, session, static, sticky) =>
// TODO very rough handling
ZIO ZIO
.fromEither(URL.decode(url)).map(url => .fromEither(URL.decode(url)).map(url =>
val req = Request(url = url) val req = Request(url = url)
@ -50,11 +91,15 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
.collectFirst { route => .collectFirst { route =>
val pathParams = route.path.decode(req.path).getOrElse(???) val pathParams = route.path.decode(req.path).getOrElse(???)
val lv = route.liveviewBuilder(pathParams, req) val lv = route.liveviewBuilder(pathParams, req)
val s = Socket(lv) val diff =
Payload.Reply("ok", LiveResponse.InitDiff(s.diff)) liveChannel.join(message.topic, session, lv)(using route.clientEventCodec)
Payload.Reply("ok", LiveResponse.InitDiff(diff))
}.getOrElse(???) }.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()) case Payload.Reply(_, _) => ZIO.die(new IllegalArgumentException())
reply.map(SocketMessage(message.joinRef, message.messageRef, message.topic, "phx_reply", _)) reply.map(SocketMessage(message.joinRef, message.messageRef, message.topic, "phx_reply", _))
@ -87,6 +132,7 @@ object SocketMessage:
val payloadParsed = eventType match val payloadParsed = eventType match
case "heartbeat" => Right(Payload.Heartbeat) case "heartbeat" => Right(Payload.Heartbeat)
case "phx_join" => payload.as[Payload.Join] case "phx_join" => payload.as[Payload.Join]
case "event" => payload.as[Payload.Event]
case s => Left(s"Unknown event type : $s") case s => Left(s"Unknown event type : $s")
payloadParsed.map( payloadParsed.map(
@ -110,6 +156,7 @@ object SocketMessage:
case Payload.Heartbeat => Json.Obj.empty case Payload.Heartbeat => Json.Obj.empty
case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException())
case p: Payload.Reply => 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], static: Option[String],
sticky: Boolean) sticky: Boolean)
case Reply(status: String, response: LiveResponse) case Reply(status: String, response: LiveResponse)
case Event(`type`: Payload.EventType, event: String, value: Map[String, String])
object Payload: object Payload:
given JsonCodec[Payload.Join] = JsonCodec.derived given JsonCodec[Payload.Join] = JsonCodec.derived
given JsonEncoder[Payload.Reply] = JsonEncoder.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: enum LiveResponse:
case Empty case Empty
case InitDiff(rendered: scalive.Diff) case InitDiff(rendered: scalive.Diff)
case Diff(diff: scalive.Diff)
object LiveResponse: object LiveResponse:
given JsonEncoder[LiveResponse] = given JsonEncoder[LiveResponse] =
JsonEncoder[Json].contramap { JsonEncoder[Json].contramap {
@ -138,5 +201,9 @@ object SocketMessage:
"liveview_version" -> Json.Str("1.1.8"), "liveview_version" -> Json.Str("1.1.8"),
"rendered" -> rendered.toJsonAST.getOrElse(throw new IllegalArgumentException()) "rendered" -> rendered.toJsonAST.getOrElse(throw new IllegalArgumentException())
) )
case Diff(diff) =>
Json.Obj(
"diff" -> diff.toJsonAST.getOrElse(throw new IllegalArgumentException())
)
} }
end SocketMessage end SocketMessage