mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +01:00
Improve LiveView's API with inspiration from TEA
This commit is contained in:
parent
4af9a78408
commit
08036ab5aa
10 changed files with 254 additions and 238 deletions
|
|
@ -2,28 +2,23 @@ package scalive
|
||||||
package playground
|
package playground
|
||||||
|
|
||||||
import scalive.*
|
import scalive.*
|
||||||
|
import zio.*
|
||||||
|
|
||||||
final case class MyModel(
|
import TestView.*
|
||||||
cls: String = "text-xs",
|
class TestView extends LiveView[Msg, Model]:
|
||||||
bool: Boolean = true,
|
|
||||||
elems: List[Elem] = List.empty)
|
|
||||||
final case class Elem(name: String, age: Int)
|
|
||||||
|
|
||||||
class TestView(initialModel: MyModel) extends LiveView[TestView.Event]:
|
def init = ZIO.succeed(Model())
|
||||||
import TestView.Event.*
|
|
||||||
|
|
||||||
private val modelVar = Var[MyModel](initialModel)
|
def update(model: Model) =
|
||||||
|
case Msg.UpdateModel(f) => ZIO.succeed(f(model))
|
||||||
|
|
||||||
def handleEvent =
|
def view(model: Dyn[Model]) =
|
||||||
case UpdateModel(f) => modelVar.update(f)
|
|
||||||
|
|
||||||
val el: HtmlElement =
|
|
||||||
div(
|
div(
|
||||||
idAttr := "42",
|
idAttr := "42",
|
||||||
cls := modelVar(_.cls),
|
cls := model(_.cls),
|
||||||
disabled := modelVar(_.bool),
|
disabled := model(_.bool),
|
||||||
ul(
|
ul(
|
||||||
modelVar(_.elems).splitByIndex((_, elem) =>
|
model(_.elems).splitByIndex((_, elem) =>
|
||||||
li(
|
li(
|
||||||
"Nom: ",
|
"Nom: ",
|
||||||
elem(_.name),
|
elem(_.name),
|
||||||
|
|
@ -35,5 +30,12 @@ class TestView(initialModel: MyModel) extends LiveView[TestView.Event]:
|
||||||
)
|
)
|
||||||
|
|
||||||
object TestView:
|
object TestView:
|
||||||
enum Event:
|
|
||||||
case UpdateModel(f: MyModel => MyModel)
|
enum Msg:
|
||||||
|
case UpdateModel(f: Model => Model)
|
||||||
|
|
||||||
|
final case class Model(
|
||||||
|
cls: String = "text-xs",
|
||||||
|
bool: Boolean = true,
|
||||||
|
elems: List[Elem] = List.empty)
|
||||||
|
final case class Elem(name: String, age: Int)
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,31 @@ package playground
|
||||||
import scalive.*
|
import scalive.*
|
||||||
import zio.json.*
|
import zio.json.*
|
||||||
|
|
||||||
extension (lv: LiveView[?])
|
extension (el: HtmlElement) def html: String = HtmlBuilder.build(el)
|
||||||
def renderHtml: String =
|
|
||||||
HtmlBuilder.build(lv.el)
|
|
||||||
|
|
||||||
|
import TestView.*
|
||||||
@main
|
@main
|
||||||
def main =
|
def main =
|
||||||
val initModel = MyModel(elems =
|
val initModel = Model(elems =
|
||||||
List(
|
List(
|
||||||
Elem("a", 10),
|
Elem("a", 10),
|
||||||
Elem("b", 15),
|
Elem("b", 15),
|
||||||
Elem("c", 30)
|
Elem("c", 30)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val lv = TestView(initModel)
|
val modelVar = Var(initModel)
|
||||||
|
val lv = TestView()
|
||||||
|
val el = lv.view(modelVar)
|
||||||
println("Init")
|
println("Init")
|
||||||
println(lv.renderHtml)
|
println(el.html)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.diff().toJsonPretty)
|
||||||
|
|
||||||
println("Edit class attribue")
|
println("Edit class attribue")
|
||||||
lv.handleEvent(
|
modelVar.update(_.copy(cls = "text-lg"))
|
||||||
TestView.Event.UpdateModel(_.copy(cls = "text-lg"))
|
println(el.diff().toJsonPretty)
|
||||||
)
|
|
||||||
println(lv.diff().toJsonPretty)
|
|
||||||
|
|
||||||
println("Edit first and last")
|
println("Edit first and last")
|
||||||
lv.handleEvent(
|
modelVar.update(
|
||||||
TestView.Event.UpdateModel(
|
|
||||||
_.copy(elems =
|
_.copy(elems =
|
||||||
List(
|
List(
|
||||||
Elem("x", 10),
|
Elem("x", 10),
|
||||||
|
|
@ -39,13 +37,11 @@ def main =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
|
||||||
|
|
||||||
println("Add one")
|
println("Add one")
|
||||||
lv.handleEvent(
|
modelVar.update(
|
||||||
TestView.Event.UpdateModel(
|
|
||||||
_.copy(elems =
|
_.copy(elems =
|
||||||
List(
|
List(
|
||||||
Elem("x", 10),
|
Elem("x", 10),
|
||||||
|
|
@ -55,13 +51,11 @@ def main =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.html)
|
||||||
println(lv.renderHtml)
|
|
||||||
|
|
||||||
println("Remove first")
|
println("Remove first")
|
||||||
lv.handleEvent(
|
modelVar.update(
|
||||||
TestView.Event.UpdateModel(
|
|
||||||
_.copy(elems =
|
_.copy(elems =
|
||||||
List(
|
List(
|
||||||
Elem("b", 15),
|
Elem("b", 15),
|
||||||
|
|
@ -70,21 +64,18 @@ def main =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.html)
|
||||||
println(lv.renderHtml)
|
|
||||||
|
|
||||||
println("Remove all")
|
println("Remove all")
|
||||||
lv.handleEvent(
|
modelVar.update(
|
||||||
TestView.Event.UpdateModel(
|
|
||||||
_.copy(
|
_.copy(
|
||||||
cls = "text-lg",
|
cls = "text-lg",
|
||||||
bool = false,
|
bool = false,
|
||||||
elems = List.empty
|
elems = List.empty
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.diff().toJsonPretty)
|
||||||
println(lv.diff().toJsonPretty)
|
println(el.html)
|
||||||
println(lv.renderHtml)
|
|
||||||
end main
|
end main
|
||||||
|
|
|
||||||
|
|
@ -46,5 +46,5 @@ object Diff:
|
||||||
case Diff.Value(value) => Json.Str(value)
|
case Diff.Value(value) => Json.Str(value)
|
||||||
case Diff.Dynamic(index, diff) =>
|
case Diff.Dynamic(index, diff) =>
|
||||||
Json.Obj(index.toString -> toJson(diff))
|
Json.Obj(index.toString -> toJson(diff))
|
||||||
case Diff.Deleted => Json.Bool(false)
|
case Diff.Deleted => Json.Str("")
|
||||||
end Diff
|
end Diff
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ extension [T](parent: Dyn[List[T]])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
class Var[T] private (initial: T) extends Dyn[T]:
|
private class Var[T] private (initial: T) extends Dyn[T]:
|
||||||
private[scalive] var currentValue: T = initial
|
private[scalive] var currentValue: T = initial
|
||||||
private[scalive] var changed: Boolean = true
|
private[scalive] var changed: Boolean = true
|
||||||
def set(value: T): Unit =
|
def set(value: T): Unit =
|
||||||
|
|
@ -61,10 +61,10 @@ class Var[T] private (initial: T) extends Dyn[T]:
|
||||||
private[scalive] def setUnchanged(): Unit = changed = false
|
private[scalive] def setUnchanged(): Unit = changed = false
|
||||||
private[scalive] inline def sync(): Unit = ()
|
private[scalive] inline def sync(): Unit = ()
|
||||||
private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue)
|
private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue)
|
||||||
object Var:
|
private object Var:
|
||||||
def apply[T](initial: T): Var[T] = new Var(initial)
|
def apply[T](initial: T): Var[T] = new Var(initial)
|
||||||
|
|
||||||
class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]:
|
private class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]:
|
||||||
private[scalive] var currentValue: O = f(parent.currentValue)
|
private[scalive] var currentValue: O = f(parent.currentValue)
|
||||||
private[scalive] var changed: Boolean = true
|
private[scalive] var changed: Boolean = true
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[
|
||||||
|
|
||||||
private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue)
|
private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue)
|
||||||
|
|
||||||
class SplitVar[I, O, Key](
|
private class SplitVar[I, O, Key](
|
||||||
parent: Dyn[List[I]],
|
parent: Dyn[List[I]],
|
||||||
key: I => Key,
|
key: I => Key,
|
||||||
project: (Key, Dyn[I]) => O):
|
project: (Key, Dyn[I]) => O):
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,18 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
|
||||||
mods.collect { case mod: (Mod.Attr & DynamicMod) => mod }
|
mods.collect { case mod: (Mod.Attr & DynamicMod) => mod }
|
||||||
def dynamicContentMods: Seq[Mod.Content & DynamicMod] =
|
def dynamicContentMods: Seq[Mod.Content & DynamicMod] =
|
||||||
mods.collect { case mod: (Mod.Content & DynamicMod) => mod }
|
mods.collect { case mod: (Mod.Content & DynamicMod) => mod }
|
||||||
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
|
|
||||||
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
|
|
||||||
|
|
||||||
def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod))
|
def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod))
|
||||||
def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
|
def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
|
||||||
|
|
||||||
|
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
|
||||||
|
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
|
||||||
|
private[scalive] def diff(trackUpdates: Boolean = true): Diff =
|
||||||
|
syncAll()
|
||||||
|
val diff = DiffBuilder.build(this, trackUpdates = trackUpdates)
|
||||||
|
setAllUnchanged()
|
||||||
|
diff
|
||||||
|
|
||||||
class HtmlTag(val name: String, val void: Boolean = false):
|
class HtmlTag(val name: String, val void: Boolean = false):
|
||||||
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
|
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
package scalive
|
package scalive
|
||||||
|
|
||||||
trait LiveView[Event]:
|
import zio.*
|
||||||
def handleEvent: Event => Unit
|
|
||||||
val el: HtmlElement
|
|
||||||
|
|
||||||
private[scalive] def diff(trackUpdates: Boolean = true): Diff =
|
trait LiveView[Msg, Model]:
|
||||||
el.syncAll()
|
def init: Task[Model]
|
||||||
val diff = DiffBuilder.build(el, trackUpdates = trackUpdates)
|
def update(model: Model): Msg => Task[Model]
|
||||||
el.setAllUnchanged()
|
def view(model: Dyn[Model]): HtmlElement
|
||||||
diff
|
// def subscriptions(model: Model): ZStream[Any, Nothing, Msg]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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 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()
|
||||||
|
|
@ -26,15 +25,12 @@ object LiveViewSpec extends TestSuite:
|
||||||
val tests = Tests {
|
val tests = Tests {
|
||||||
|
|
||||||
test("Static only") {
|
test("Static only") {
|
||||||
val lv =
|
|
||||||
new LiveView[Nothing]:
|
|
||||||
val el = div("Static string")
|
val el = div("Static string")
|
||||||
def handleEvent = _ => ()
|
el.syncAll()
|
||||||
lv.el.syncAll()
|
|
||||||
|
|
||||||
test("init") {
|
test("init") {
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
"s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
||||||
),
|
),
|
||||||
|
|
@ -42,27 +38,24 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff") {
|
test("diff") {
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Dynamic string") {
|
test("Dynamic string") {
|
||||||
val lv =
|
|
||||||
new LiveView[UpdateEvent]:
|
|
||||||
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 handleEvent = evt => model.update(evt.f)
|
|
||||||
|
|
||||||
lv.el.syncAll()
|
el.syncAll()
|
||||||
lv.el.setAllUnchanged()
|
el.setAllUnchanged()
|
||||||
|
|
||||||
test("init") {
|
test("init") {
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json
|
Json
|
||||||
.Obj(
|
.Obj(
|
||||||
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
|
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
|
||||||
|
|
@ -73,24 +66,24 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff no update") {
|
test("diff no update") {
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with update") {
|
test("diff with update") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(title = "title updated")))
|
model.update(_.copy(title = "title updated"))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
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.handleEvent(UpdateEvent(_.copy(title = "title value")))
|
model.update(_.copy(title = "title value"))
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with update in multiple commands") {
|
test("diff with update in multiple commands") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(title = "title updated")))
|
model.update(_.copy(title = "title updated"))
|
||||||
lv.handleEvent(UpdateEvent(_.copy(otherString = "other string updated")))
|
model.update(_.copy(otherString = "other string updated"))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json
|
Json
|
||||||
.Obj(
|
.Obj(
|
||||||
"0" -> Json.Str("title updated"),
|
"0" -> Json.Str("title updated"),
|
||||||
|
|
@ -101,19 +94,16 @@ object LiveViewSpec extends TestSuite:
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Dynamic attribute") {
|
test("Dynamic attribute") {
|
||||||
val lv =
|
|
||||||
new LiveView[UpdateEvent]:
|
|
||||||
val model = Var(TestModel())
|
val model = Var(TestModel())
|
||||||
val el =
|
val el =
|
||||||
div(cls := model(_.cls))
|
div(cls := model(_.cls))
|
||||||
def handleEvent = evt => model.update(evt.f)
|
|
||||||
|
|
||||||
lv.el.syncAll()
|
el.syncAll()
|
||||||
lv.el.setAllUnchanged()
|
el.setAllUnchanged()
|
||||||
|
|
||||||
test("init") {
|
test("init") {
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json
|
Json
|
||||||
.Obj(
|
.Obj(
|
||||||
"s" -> Json
|
"s" -> Json
|
||||||
|
|
@ -124,20 +114,18 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff no update") {
|
test("diff no update") {
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with update") {
|
test("diff with update") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(cls = "text-md")))
|
model.update(_.copy(cls = "text-md"))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj("0" -> Json.Str("text-md"))
|
Json.Obj("0" -> Json.Str("text-md"))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("when mod") {
|
test("when mod") {
|
||||||
val lv =
|
|
||||||
new LiveView[UpdateEvent]:
|
|
||||||
val model = Var(TestModel())
|
val model = Var(TestModel())
|
||||||
val el =
|
val el =
|
||||||
div(
|
div(
|
||||||
|
|
@ -145,14 +133,13 @@ object LiveViewSpec extends TestSuite:
|
||||||
div("static string", model(_.nestedTitle))
|
div("static string", model(_.nestedTitle))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
def handleEvent = evt => model.update(evt.f)
|
|
||||||
|
|
||||||
lv.el.syncAll()
|
el.syncAll()
|
||||||
lv.el.setAllUnchanged()
|
el.setAllUnchanged()
|
||||||
|
|
||||||
test("init") {
|
test("init") {
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json
|
Json
|
||||||
.Obj(
|
.Obj(
|
||||||
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
||||||
|
|
@ -162,16 +149,16 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff no update") {
|
test("diff no update") {
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with unrelated update") {
|
test("diff with unrelated update") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(title = "title updated")))
|
model.update(_.copy(title = "title updated"))
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff when true and nested update") {
|
test("diff when true and nested update") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(bool = true)))
|
model.update(_.copy(bool = true))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
@ -184,12 +171,12 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff when nested change") {
|
test("diff when nested change") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(bool = true)))
|
model.update(_.copy(bool = true))
|
||||||
lv.el.syncAll()
|
el.syncAll()
|
||||||
lv.el.setAllUnchanged()
|
el.setAllUnchanged()
|
||||||
lv.handleEvent(UpdateEvent(_.copy(bool = true, nestedTitle = "nested title updated")))
|
model.update(_.copy(bool = true, nestedTitle = "nested title updated"))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
@ -209,8 +196,6 @@ object LiveViewSpec extends TestSuite:
|
||||||
NestedModel("c", 20)
|
NestedModel("c", 20)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val lv =
|
|
||||||
new LiveView[UpdateEvent]:
|
|
||||||
val model = Var(initModel)
|
val model = Var(initModel)
|
||||||
val el =
|
val el =
|
||||||
div(
|
div(
|
||||||
|
|
@ -225,14 +210,13 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
def handleEvent = evt => model.update(evt.f)
|
|
||||||
|
|
||||||
lv.el.syncAll()
|
el.syncAll()
|
||||||
lv.el.setAllUnchanged()
|
el.setAllUnchanged()
|
||||||
|
|
||||||
test("init") {
|
test("init") {
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json
|
Json
|
||||||
.Obj(
|
.Obj(
|
||||||
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
||||||
|
|
@ -263,18 +247,16 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff no update") {
|
test("diff no update") {
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with unrelated update") {
|
test("diff with unrelated update") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(title = "title updated")))
|
model.update(_.copy(title = "title updated"))
|
||||||
assertEqualsDiff(lv.el, emptyDiff)
|
assertEqualsDiff(el, emptyDiff)
|
||||||
}
|
}
|
||||||
test("diff with item changed") {
|
test("diff with item changed") {
|
||||||
lv.handleEvent(
|
model.update(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
|
||||||
UpdateEvent(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
|
|
||||||
)
|
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
@ -290,13 +272,9 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff with item added") {
|
test("diff with item added") {
|
||||||
lv.handleEvent(
|
model.update(_.copy(items = initModel.items.appended(NestedModel("d", 35))))
|
||||||
UpdateEvent(
|
|
||||||
_.copy(items = initModel.items.appended(NestedModel("d", 35)))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
@ -313,13 +291,9 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff with first item removed") {
|
test("diff with first item removed") {
|
||||||
lv.handleEvent(
|
model.update(_.copy(items = initModel.items.tail))
|
||||||
UpdateEvent(
|
|
||||||
_.copy(items = initModel.items.tail)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
@ -340,9 +314,9 @@ object LiveViewSpec extends TestSuite:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
test("diff all removed") {
|
test("diff all removed") {
|
||||||
lv.handleEvent(UpdateEvent(_.copy(items = List.empty)))
|
model.update(_.copy(items = List.empty))
|
||||||
assertEqualsDiff(
|
assertEqualsDiff(
|
||||||
lv.el,
|
el,
|
||||||
Json.Obj(
|
Json.Obj(
|
||||||
"0" ->
|
"0" ->
|
||||||
Json
|
Json
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import ExampleLiveView.Event
|
|
||||||
import monocle.syntax.all.*
|
import monocle.syntax.all.*
|
||||||
import scalive.*
|
import scalive.*
|
||||||
|
import zio.*
|
||||||
import zio.json.*
|
import zio.json.*
|
||||||
|
|
||||||
final case class ExampleModel(elems: List[NestedModel], cls: String = "text-xs")
|
import ExampleLiveView.*
|
||||||
final case class NestedModel(name: String, age: Int)
|
class ExampleLiveView(someParam: String) extends LiveView[Msg, Model]:
|
||||||
|
|
||||||
class ExampleLiveView(someParam: String) extends LiveView[Event]:
|
def init = ZIO.succeed(
|
||||||
|
Model(
|
||||||
val model = Var(
|
isVisible = true,
|
||||||
ExampleModel(
|
counter = 0,
|
||||||
List(
|
elems = List(
|
||||||
NestedModel("a", 10),
|
NestedModel("a", 10),
|
||||||
NestedModel("b", 15),
|
NestedModel("b", 15),
|
||||||
NestedModel("c", 20)
|
NestedModel("c", 20)
|
||||||
|
|
@ -18,11 +18,17 @@ class ExampleLiveView(someParam: String) extends LiveView[Event]:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def handleEvent =
|
def update(model: Model) =
|
||||||
case Event.Event(value) =>
|
case Msg.IncAge(value) =>
|
||||||
model.update(_.focus(_.elems.index(2).age).modify(_ + value))
|
ZIO.succeed(model.focus(_.elems.index(2).age).modify(_ + value))
|
||||||
|
case Msg.ToggleCounter =>
|
||||||
|
ZIO.succeed(model.focus(_.isVisible).modify(!_))
|
||||||
|
case Msg.IncCounter =>
|
||||||
|
ZIO.succeed(model.focus(_.counter).modify(_ + 1))
|
||||||
|
case Msg.DecCounter =>
|
||||||
|
ZIO.succeed(model.focus(_.counter).modify(_ - 1))
|
||||||
|
|
||||||
val el =
|
def view(model: Dyn[Model]) =
|
||||||
div(
|
div(
|
||||||
h1(someParam),
|
h1(someParam),
|
||||||
h2(model(_.cls)),
|
h2(model(_.cls)),
|
||||||
|
|
@ -39,12 +45,39 @@ class ExampleLiveView(someParam: String) extends LiveView[Event]:
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
button(
|
button(
|
||||||
phx.click := Event.Event(1),
|
phx.click := Msg.IncAge(1),
|
||||||
"Inc age"
|
"Inc age"
|
||||||
|
),
|
||||||
|
br(),
|
||||||
|
br(),
|
||||||
|
br(),
|
||||||
|
button(
|
||||||
|
phx.click := Msg.ToggleCounter,
|
||||||
|
model(_.isVisible match
|
||||||
|
case true => "Hide counter"
|
||||||
|
case false => "Show counter")
|
||||||
|
),
|
||||||
|
model.when(_.isVisible)(
|
||||||
|
div(
|
||||||
|
button(phx.click := Msg.DecCounter, "-"),
|
||||||
|
div(model(_.counter.toString)),
|
||||||
|
button(phx.click := Msg.IncCounter, "+")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end ExampleLiveView
|
end ExampleLiveView
|
||||||
|
|
||||||
object ExampleLiveView:
|
object ExampleLiveView:
|
||||||
enum Event derives JsonCodec:
|
|
||||||
case Event(value: Int)
|
enum Msg derives JsonCodec:
|
||||||
|
case IncAge(value: Int)
|
||||||
|
case ToggleCounter
|
||||||
|
case IncCounter
|
||||||
|
case DecCounter
|
||||||
|
|
||||||
|
final case class Model(
|
||||||
|
isVisible: Boolean,
|
||||||
|
counter: Int,
|
||||||
|
elems: List[NestedModel],
|
||||||
|
cls: String = "text-xs")
|
||||||
|
final case class NestedModel(name: String, age: Int)
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,22 @@ import zio.stream.ZStream
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import scala.util.Random
|
import scala.util.Random
|
||||||
|
|
||||||
final case class LiveRoute[A, Event: JsonCodec](
|
final case class LiveRoute[A, Msg: JsonCodec, Model](
|
||||||
path: PathCodec[A],
|
path: PathCodec[A],
|
||||||
liveviewBuilder: (A, Request) => LiveView[Event]):
|
liveviewBuilder: (A, Request) => LiveView[Msg, Model]):
|
||||||
val eventCodec = JsonCodec[Event]
|
val messageCodec = JsonCodec[Msg]
|
||||||
|
|
||||||
def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Nothing] =
|
def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Throwable] =
|
||||||
Method.GET / path -> handler { (params: A, req: Request) =>
|
Method.GET / path -> handler { (params: A, req: Request) =>
|
||||||
val lv = liveviewBuilder(params, req)
|
val lv = liveviewBuilder(params, req)
|
||||||
val id: String =
|
val id: String =
|
||||||
s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))}"
|
s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))}"
|
||||||
val token = Token.sign("secret", id, "")
|
val token = Token.sign("secret", id, "")
|
||||||
lv.el.syncAll()
|
for
|
||||||
Response.html(
|
initModel <- lv.init
|
||||||
|
el = lv.view(Var(initModel))
|
||||||
|
_ = el.syncAll()
|
||||||
|
yield Response.html(
|
||||||
Html.raw(
|
Html.raw(
|
||||||
HtmlBuilder.build(
|
HtmlBuilder.build(
|
||||||
rootLayout(
|
rootLayout(
|
||||||
|
|
@ -35,15 +38,16 @@ final case class LiveRoute[A, Event: JsonCodec](
|
||||||
idAttr := id,
|
idAttr := id,
|
||||||
phx.main := true,
|
phx.main := true,
|
||||||
phx.session := token,
|
phx.session := token,
|
||||||
lv.el
|
el
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
end LiveRoute
|
||||||
|
|
||||||
class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]):
|
class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]]):
|
||||||
def diffsStream: ZStream[Any, Nothing, (LiveResponse, Meta)] =
|
def diffsStream: ZStream[Any, Nothing, (LiveResponse, Meta)] =
|
||||||
sockets.changes
|
sockets.changes
|
||||||
.map(m =>
|
.map(m =>
|
||||||
|
|
@ -51,12 +55,12 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]):
|
||||||
.mergeAllUnbounded()(m.values.map(_.outbox).toList*)
|
.mergeAllUnbounded()(m.values.map(_.outbox).toList*)
|
||||||
).flatMapParSwitch(1, 1)(identity)
|
).flatMapParSwitch(1, 1)(identity)
|
||||||
|
|
||||||
def join[Event: JsonCodec](
|
def join[Msg: JsonCodec, Model](
|
||||||
id: String,
|
id: String,
|
||||||
token: String,
|
token: String,
|
||||||
lv: LiveView[Event],
|
lv: LiveView[Msg, Model],
|
||||||
meta: WebSocketMessage.Meta
|
meta: WebSocketMessage.Meta
|
||||||
): URIO[Scope, Unit] =
|
): RIO[Scope, Unit] =
|
||||||
sockets.updateZIO { m =>
|
sockets.updateZIO { m =>
|
||||||
m.get(id) match
|
m.get(id) match
|
||||||
case Some(socket) =>
|
case Some(socket) =>
|
||||||
|
|
@ -78,7 +82,7 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?]]]):
|
||||||
socket.inbox
|
socket.inbox
|
||||||
.offer(
|
.offer(
|
||||||
value
|
value
|
||||||
.fromJson(using socket.clientEventCodec.decoder)
|
.fromJson(using socket.messageCodec.decoder)
|
||||||
.getOrElse(throw new IllegalArgumentException())
|
.getOrElse(throw new IllegalArgumentException())
|
||||||
-> meta
|
-> meta
|
||||||
).unit
|
).unit
|
||||||
|
|
@ -91,7 +95,7 @@ object LiveChannel:
|
||||||
def make(): UIO[LiveChannel] =
|
def make(): UIO[LiveChannel] =
|
||||||
SubscriptionRef.make(Map.empty).map(new LiveChannel(_))
|
SubscriptionRef.make(Map.empty).map(new LiveChannel(_))
|
||||||
|
|
||||||
class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?]]):
|
class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRoute[?, ?, ?]]):
|
||||||
|
|
||||||
private val socketApp: WebSocketApp[Any] =
|
private val socketApp: WebSocketApp[Any] =
|
||||||
Handler.webSocket { channel =>
|
Handler.webSocket { channel =>
|
||||||
|
|
@ -155,7 +159,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
|
||||||
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)
|
||||||
liveChannel
|
liveChannel
|
||||||
.join(message.topic, session, lv, message.meta)(using route.eventCodec)
|
.join(message.topic, session, lv, message.meta)(using route.messageCodec)
|
||||||
.map(_ => None)
|
.map(_ => None)
|
||||||
|
|
||||||
}.getOrElse(ZIO.succeed(None))
|
}.getOrElse(ZIO.succeed(None))
|
||||||
|
|
@ -168,12 +172,15 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
|
||||||
end match
|
end match
|
||||||
end handleMessage
|
end handleMessage
|
||||||
|
|
||||||
val routes: Routes[Any, Response] =
|
val routes: Routes[Any, Nothing] =
|
||||||
Routes.fromIterable(
|
Routes
|
||||||
|
.fromIterable(
|
||||||
liveRoutes
|
liveRoutes
|
||||||
.map(route => route.toZioRoute(rootLayout))
|
.map(route => route.toZioRoute(rootLayout))
|
||||||
.prepended(
|
.prepended(
|
||||||
Method.GET / "live" / "websocket" -> handler(socketApp.toResponse)
|
Method.GET / "live" / "websocket" -> handler(socketApp.toResponse)
|
||||||
)
|
)
|
||||||
|
).handleErrorZIO(e =>
|
||||||
|
ZIO.logErrorCause(Cause.die(e)).as(Response(status = Status.InternalServerError))
|
||||||
)
|
)
|
||||||
end LiveRouter
|
end LiveRouter
|
||||||
|
|
|
||||||
|
|
@ -6,34 +6,38 @@ import zio.Queue
|
||||||
import zio.json.*
|
import zio.json.*
|
||||||
import zio.stream.ZStream
|
import zio.stream.ZStream
|
||||||
|
|
||||||
final case class Socket[Event: JsonCodec] private (
|
final case class Socket[Msg: JsonCodec, Model] private (
|
||||||
id: String,
|
id: String,
|
||||||
token: String,
|
token: String,
|
||||||
inbox: Queue[(Event, WebSocketMessage.Meta)],
|
inbox: Queue[(Msg, WebSocketMessage.Meta)],
|
||||||
outbox: ZStream[Any, Nothing, (LiveResponse, WebSocketMessage.Meta)],
|
outbox: ZStream[Any, Nothing, (LiveResponse, WebSocketMessage.Meta)],
|
||||||
fiber: Fiber.Runtime[Nothing, Unit],
|
fiber: Fiber.Runtime[Throwable, Unit],
|
||||||
shutdown: UIO[Unit]):
|
shutdown: UIO[Unit]):
|
||||||
val clientEventCodec = JsonCodec[Event]
|
val messageCodec = JsonCodec[Msg]
|
||||||
|
|
||||||
object Socket:
|
object Socket:
|
||||||
def start[Event: JsonCodec](
|
def start[Msg: JsonCodec, Model](
|
||||||
id: String,
|
id: String,
|
||||||
token: String,
|
token: String,
|
||||||
lv: LiveView[Event],
|
lv: LiveView[Msg, Model],
|
||||||
meta: WebSocketMessage.Meta
|
meta: WebSocketMessage.Meta
|
||||||
): URIO[Scope, Socket[Event]] =
|
): RIO[Scope, Socket[Msg, Model]] =
|
||||||
for
|
for
|
||||||
inbox <- Queue.bounded[(Event, WebSocketMessage.Meta)](4)
|
inbox <- Queue.bounded[(Msg, WebSocketMessage.Meta)](4)
|
||||||
outHub <- Hub.bounded[(LiveResponse, WebSocketMessage.Meta)](4)
|
outHub <- Hub.bounded[(LiveResponse, WebSocketMessage.Meta)](4)
|
||||||
initDiff = lv.diff(trackUpdates = false)
|
initModel <- lv.init
|
||||||
lvRef <- Ref.make(lv)
|
modelVar = Var(initModel)
|
||||||
|
el = lv.view(modelVar)
|
||||||
|
ref <- Ref.make((modelVar, el))
|
||||||
|
initDiff = el.diff(trackUpdates = false)
|
||||||
fiber <- ZStream
|
fiber <- ZStream
|
||||||
.fromQueue(inbox)
|
.fromQueue(inbox)
|
||||||
.mapZIO { (msg, meta) =>
|
.mapZIO { (msg, meta) =>
|
||||||
for
|
for
|
||||||
lv <- lvRef.get
|
(modelVar, el) <- ref.get
|
||||||
_ = lv.handleEvent(msg)
|
updatedModel <- lv.update(modelVar.currentValue)(msg)
|
||||||
diff = lv.diff()
|
_ = modelVar.set(updatedModel)
|
||||||
|
diff = el.diff()
|
||||||
_ <- outHub.publish(LiveResponse.Diff(diff) -> meta)
|
_ <- outHub.publish(LiveResponse.Diff(diff) -> meta)
|
||||||
yield ()
|
yield ()
|
||||||
}
|
}
|
||||||
|
|
@ -42,4 +46,5 @@ object Socket:
|
||||||
stop = inbox.shutdown *> outHub.shutdown *> fiber.interrupt.unit
|
stop = inbox.shutdown *> outHub.shutdown *> fiber.interrupt.unit
|
||||||
diffStream <- ZStream.fromHubScoped(outHub)
|
diffStream <- ZStream.fromHubScoped(outHub)
|
||||||
outbox = ZStream.succeed(LiveResponse.InitDiff(initDiff) -> meta) ++ diffStream
|
outbox = ZStream.succeed(LiveResponse.InitDiff(initDiff) -> meta) ++ diffStream
|
||||||
yield Socket[Event](id, token, inbox, outbox, fiber, stop)
|
yield Socket[Msg, Model](id, token, inbox, outbox, fiber, stop)
|
||||||
|
end Socket
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue