mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +01:00
Support click events
This commit is contained in:
parent
1bd65fd49c
commit
124239925d
16 changed files with 277 additions and 162 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) =>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)] =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue