mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +01:00
Rework diff aware state management allowing an API similar to Laminar
This commit is contained in:
parent
8f19ccfbb4
commit
cff02a4c96
17 changed files with 857 additions and 710 deletions
|
|
@ -7,7 +7,13 @@ import mill.api.Task.Simple
|
|||
|
||||
trait Common extends ScalaModule:
|
||||
def scalaVersion = "3.7.2"
|
||||
def scalacOptions = Seq("-Wunused:all", "-preview", "-feature", "-language:implicitConversions")
|
||||
def scalacOptions = Seq(
|
||||
"-Wunused:all",
|
||||
"-preview",
|
||||
"-feature",
|
||||
"-language:implicitConversions",
|
||||
"-Wvalue-discard"
|
||||
)
|
||||
|
||||
object core extends Common:
|
||||
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")
|
||||
|
|
|
|||
|
|
@ -1,41 +1,27 @@
|
|||
import scalive.*
|
||||
|
||||
final case class MyModel(cls: String = "text-xs", bool: Boolean = true)
|
||||
final case class MyModel(
|
||||
cls: String = "text-xs",
|
||||
bool: Boolean = true,
|
||||
elems: List[Elem] = List.empty)
|
||||
final case class Elem(name: String, age: Int)
|
||||
|
||||
class TestView extends LiveView[TestView.Cmd]:
|
||||
class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]:
|
||||
import TestView.Cmd.*
|
||||
|
||||
private val textCls = Dyn[String]
|
||||
private val someBool = Dyn[Boolean]
|
||||
private val elems = Dyn[List[Elem]]
|
||||
private val modelVar = Var[MyModel](initialModel)
|
||||
|
||||
def mount(state: LiveState): LiveState =
|
||||
state
|
||||
.set(textCls, "text-xs")
|
||||
.set(someBool, true)
|
||||
.set(
|
||||
elems,
|
||||
List(
|
||||
Elem("a", 10),
|
||||
Elem("b", 15),
|
||||
Elem("c", 20)
|
||||
)
|
||||
)
|
||||
def handleCommand(cmd: TestView.Cmd): Unit =
|
||||
cmd match
|
||||
case UpdateModel(f) => modelVar.update(f)
|
||||
|
||||
def handleCommand(cmd: TestView.Cmd, state: LiveState): LiveState = cmd match
|
||||
case UpdateElems(es) => state.set(elems, es)
|
||||
case UpdateBool(b) => state.set(someBool, b)
|
||||
case UpdateTextCls(cls) => state.set(textCls, cls)
|
||||
|
||||
val render =
|
||||
val el: HtmlElement =
|
||||
div(
|
||||
idAttr := "42",
|
||||
cls := textCls,
|
||||
draggable := someBool,
|
||||
disabled := someBool,
|
||||
cls := modelVar(_.cls),
|
||||
disabled := modelVar(_.bool),
|
||||
ul(
|
||||
elems.splitByIndex(elem =>
|
||||
modelVar(_.elems).splitByIndex((_, elem) =>
|
||||
li(
|
||||
"Nom: ",
|
||||
elem(_.name),
|
||||
|
|
@ -45,10 +31,7 @@ class TestView extends LiveView[TestView.Cmd]:
|
|||
)
|
||||
)
|
||||
)
|
||||
end TestView
|
||||
|
||||
object TestView:
|
||||
enum Cmd:
|
||||
case UpdateElems(es: List[Elem])
|
||||
case UpdateBool(b: Boolean)
|
||||
case UpdateTextCls(cls: String)
|
||||
case UpdateModel(f: MyModel => MyModel)
|
||||
|
|
|
|||
|
|
@ -2,21 +2,29 @@ import scalive.*
|
|||
|
||||
@main
|
||||
def main =
|
||||
val s = Socket(TestView())
|
||||
val initModel = MyModel(elems =
|
||||
List(
|
||||
Elem("a", 10),
|
||||
Elem("b", 15),
|
||||
Elem("c", 30)
|
||||
)
|
||||
)
|
||||
val s = Socket(TestView(initModel))
|
||||
println("Init")
|
||||
println(s.renderHtml)
|
||||
s.syncClient
|
||||
s.syncClient
|
||||
|
||||
println("Edit first and last")
|
||||
println("Edit class attribue")
|
||||
s.receiveCommand(
|
||||
TestView.Cmd.UpdateTextCls("text-lg")
|
||||
TestView.Cmd.UpdateModel(_.copy(cls = "text-lg"))
|
||||
)
|
||||
s.syncClient
|
||||
|
||||
println("Edit first and last")
|
||||
s.receiveCommand(
|
||||
TestView.Cmd.UpdateElems(
|
||||
TestView.Cmd.UpdateModel(
|
||||
_.copy(elems =
|
||||
List(
|
||||
Elem("x", 10),
|
||||
Elem("b", 15),
|
||||
|
|
@ -24,11 +32,14 @@ def main =
|
|||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
s.syncClient
|
||||
println(s.renderHtml)
|
||||
|
||||
println("Add one")
|
||||
s.receiveCommand(
|
||||
TestView.Cmd.UpdateElems(
|
||||
TestView.Cmd.UpdateModel(
|
||||
_.copy(elems =
|
||||
List(
|
||||
Elem("x", 10),
|
||||
Elem("b", 15),
|
||||
|
|
@ -37,26 +48,36 @@ def main =
|
|||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
s.syncClient
|
||||
println(s.renderHtml)
|
||||
|
||||
//
|
||||
// println("Remove first")
|
||||
// lv.update(
|
||||
// MyModel(
|
||||
// List(
|
||||
// NestedModel("b", 15),
|
||||
// NestedModel("c", 99),
|
||||
// NestedModel("d", 35)
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// println(lv.diff.toJsonPretty)
|
||||
// println(HtmlBuilder.build(lv))
|
||||
//
|
||||
// println("Remove all")
|
||||
// lv.update(
|
||||
// MyModel(List.empty, "text-lg", bool = false)
|
||||
// )
|
||||
// println(lv.diff.toJsonPretty)
|
||||
// println(HtmlBuilder.build(lv))
|
||||
println("Remove first")
|
||||
s.receiveCommand(
|
||||
TestView.Cmd.UpdateModel(
|
||||
_.copy(elems =
|
||||
List(
|
||||
Elem("b", 15),
|
||||
Elem("c", 99),
|
||||
Elem("d", 35)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
s.syncClient
|
||||
println(s.renderHtml)
|
||||
|
||||
println("Remove all")
|
||||
s.receiveCommand(
|
||||
TestView.Cmd.UpdateModel(
|
||||
_.copy(
|
||||
cls = "text-lg",
|
||||
bool = false,
|
||||
elems = List.empty
|
||||
)
|
||||
)
|
||||
)
|
||||
s.syncClient
|
||||
s.syncClient
|
||||
println(s.renderHtml)
|
||||
end main
|
||||
|
|
|
|||
50
core/src/scalive/Diff.scala
Normal file
50
core/src/scalive/Diff.scala
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package scalive
|
||||
|
||||
import zio.Chunk
|
||||
import zio.json.JsonEncoder
|
||||
import zio.json.ast.Json
|
||||
|
||||
enum Diff:
|
||||
case Tag(
|
||||
static: Seq[String] = Seq.empty,
|
||||
dynamic: Seq[Diff.Dynamic] = Seq.empty)
|
||||
case Split(
|
||||
static: Seq[String] = Seq.empty,
|
||||
entries: Seq[Diff.Dynamic] = Seq.empty)
|
||||
case Value(value: String)
|
||||
case Dynamic(key: String, diff: Diff)
|
||||
case Deleted
|
||||
|
||||
object Diff:
|
||||
given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson)
|
||||
|
||||
private def toJson(diff: Diff): Json =
|
||||
diff match
|
||||
case Diff.Tag(static, dynamic) =>
|
||||
Json.Obj(
|
||||
Option
|
||||
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
|
||||
.to(Chunk)
|
||||
.appendedAll(
|
||||
dynamic.map(d => d.key -> toJson(d.diff))
|
||||
)
|
||||
)
|
||||
case Diff.Split(static, entries) =>
|
||||
Json.Obj(
|
||||
Option
|
||||
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
|
||||
.to(Chunk)
|
||||
.appendedAll(
|
||||
Option.when(entries.nonEmpty)(
|
||||
"d" ->
|
||||
Json.Obj(
|
||||
entries.map(d => d.key -> toJson(d.diff))*
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
case Diff.Value(value) => Json.Str(value)
|
||||
case Diff.Dynamic(index, diff) =>
|
||||
Json.Obj(index.toString -> toJson(diff))
|
||||
case Diff.Deleted => Json.Bool(false)
|
||||
end Diff
|
||||
|
|
@ -1,53 +1,62 @@
|
|||
package scalive
|
||||
|
||||
import zio.Chunk
|
||||
import zio.json.ast.Json
|
||||
import scalive.Mod.Attr
|
||||
import scalive.Mod.Content
|
||||
|
||||
object DiffBuilder:
|
||||
def build(rendered: Rendered, fingerprint: Fingerprint): Json =
|
||||
val nestedFingerprintIter = fingerprint.nested.iterator
|
||||
Json.Obj(
|
||||
Option
|
||||
.when(rendered.static.nonEmpty && fingerprint.value != rendered.fingerprint)(
|
||||
"s" -> Json.Arr(rendered.static.map(Json.Str(_))*)
|
||||
)
|
||||
.to(Chunk)
|
||||
.appendedAll(
|
||||
rendered.dynamic.zipWithIndex
|
||||
.map((render, index) => index.toString -> build(render(true), nestedFingerprintIter))
|
||||
).filterNot(_._2 == Json.Obj.empty)
|
||||
def build(el: HtmlElement, trackUpdates: Boolean = true): Diff =
|
||||
build(
|
||||
static = if trackUpdates then Seq.empty else el.static,
|
||||
dynamicMods = el.dynamicMods,
|
||||
trackUpdates = trackUpdates
|
||||
)
|
||||
|
||||
private def build(comp: Comprehension, fingerprint: Fingerprint): Json =
|
||||
val nestedFingerprintIter = fingerprint.nested.iterator
|
||||
Json.Obj(
|
||||
Option
|
||||
.when(comp.static.nonEmpty && fingerprint.value != comp.fingerprint)(
|
||||
"s" -> Json.Arr(comp.static.map(Json.Str(_))*)
|
||||
)
|
||||
.to(Chunk)
|
||||
.appendedAll(
|
||||
Option.when(comp.entries.nonEmpty)(
|
||||
"d" ->
|
||||
Json.Arr(
|
||||
comp.entries.map(render =>
|
||||
Json.Obj(
|
||||
render(true).zipWithIndex
|
||||
.map((dyn, index) =>
|
||||
index.toString -> build(dyn, nestedFingerprintIter)
|
||||
).filterNot(_._2 == Json.Obj.empty)*
|
||||
)
|
||||
)*
|
||||
)
|
||||
)
|
||||
)
|
||||
private def build(static: Seq[String], dynamicMods: Seq[DynamicMod], trackUpdates: Boolean)
|
||||
: Diff =
|
||||
Diff.Tag(
|
||||
static = static,
|
||||
dynamic =
|
||||
buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) =>
|
||||
Diff.Dynamic(index.toString, diff)
|
||||
}
|
||||
)
|
||||
|
||||
private def build(dyn: RenderedDyn, fingerprintIter: Iterator[Fingerprint]): Json =
|
||||
dyn match
|
||||
case Some(s: String) => Json.Str(s)
|
||||
case Some(r: Rendered) => build(r, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
|
||||
case Some(c: Comprehension) =>
|
||||
build(c, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
|
||||
case None => Json.Obj.empty
|
||||
private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] =
|
||||
dynamicMods.flatMap {
|
||||
case Attr.Dyn(attr, value) =>
|
||||
List(value.render(trackUpdates).map(v => Diff.Value(v.toString)))
|
||||
case Attr.DynValueAsPresence(attr, value) =>
|
||||
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else "")))
|
||||
case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates)
|
||||
case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_)))
|
||||
case Content.DynElement(dyn) => ???
|
||||
case Content.DynOptionElement(dyn) =>
|
||||
List(dyn.render(trackUpdates) match
|
||||
// Element is added
|
||||
case Some(Some(el)) => Some(build(el, trackUpdates = false))
|
||||
// Element is removed
|
||||
case Some(None) => Some(Diff.Deleted)
|
||||
// Element is updated if present
|
||||
case None => dyn.currentValue.map(build(_, trackUpdates)))
|
||||
case Content.DynElementColl(dyn) => ???
|
||||
case Content.DynSplit(splitVar) =>
|
||||
val entries = splitVar.render(trackUpdates)
|
||||
if entries.isEmpty then List.empty
|
||||
else
|
||||
val static =
|
||||
entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty)
|
||||
List(
|
||||
Some(
|
||||
Diff.Split(
|
||||
static = if trackUpdates then Seq.empty else static,
|
||||
entries = entries.map {
|
||||
case (key, Some(el)) =>
|
||||
Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates))
|
||||
case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
end DiffBuilder
|
||||
|
|
|
|||
136
core/src/scalive/Dyn.scala
Normal file
136
core/src/scalive/Dyn.scala
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package scalive
|
||||
|
||||
import scala.collection.mutable
|
||||
|
||||
/** Represents a mutable dynamic value that keeps track of its own state and whether it was changed
|
||||
* or not. It doesn't observe its parent, therefore sync() must be called manually for updates to
|
||||
* propagate. Likewise, setUnchanged must be called to reset the change tracking once the latest
|
||||
* diff has been sent to the client.
|
||||
*
|
||||
* Observables are not used on purpose to avoid the complexity of managing their cleanup. The
|
||||
* tradeoff of micro managing the updates is acceptable as it is only done internally.
|
||||
*/
|
||||
sealed trait Dyn[T]:
|
||||
private[scalive] def currentValue: T
|
||||
private[scalive] def changed: Boolean
|
||||
|
||||
def apply[T2](f: T => T2): Dyn[T2]
|
||||
|
||||
def when(zoom: T => Boolean)(el: HtmlElement): Mod =
|
||||
this match
|
||||
case v: Var[T] => Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el)))
|
||||
case v: DerivedVar[?, T] =>
|
||||
Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el)))
|
||||
|
||||
inline def whenNot(f: T => Boolean)(el: HtmlElement): Mod =
|
||||
when(f.andThen(!_))(el)
|
||||
|
||||
private[scalive] def render(trackUpdates: Boolean): Option[T]
|
||||
|
||||
/** Dynamic values do not observe their parent state, sync() must be called manually to update the
|
||||
* currentValue.
|
||||
*/
|
||||
private[scalive] def sync(): Unit
|
||||
|
||||
private[scalive] def setUnchanged(): Unit
|
||||
|
||||
private[scalive] def callOnEveryChild(f: T => Unit): Unit
|
||||
|
||||
extension [T](parent: Dyn[List[T]])
|
||||
def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod =
|
||||
Mod.Content.DynSplit(
|
||||
new SplitVar(
|
||||
parent.apply(_.zipWithIndex),
|
||||
key = _._2,
|
||||
project = (index, v) => project(index, v(_._1))
|
||||
)
|
||||
)
|
||||
|
||||
class Var[T] private (initial: T) extends Dyn[T]:
|
||||
private[scalive] var currentValue: T = initial
|
||||
private[scalive] var changed: Boolean = true
|
||||
def set(value: T): Unit =
|
||||
if value != currentValue then
|
||||
changed = true
|
||||
currentValue = value
|
||||
def update(f: T => T): Unit = set(f(currentValue))
|
||||
def apply[T2](f: T => T2): DerivedVar[T, T2] = new DerivedVar(this, f)
|
||||
private[scalive] def render(trackUpdates: Boolean): Option[T] =
|
||||
if !trackUpdates || changed then Some(currentValue)
|
||||
else None
|
||||
private[scalive] def setUnchanged(): Unit = changed = false
|
||||
private[scalive] inline def sync(): Unit = ()
|
||||
private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue)
|
||||
object Var:
|
||||
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[scalive] var currentValue: O = f(parent.currentValue)
|
||||
private[scalive] var changed: Boolean = true
|
||||
|
||||
def apply[O2](zoom: O => O2): DerivedVar[I, O2] =
|
||||
new DerivedVar(parent, f.andThen(zoom))
|
||||
|
||||
private[scalive] def sync(): Unit =
|
||||
if parent.changed then
|
||||
val value = f(parent.currentValue)
|
||||
if value != currentValue then
|
||||
changed = true
|
||||
currentValue = value
|
||||
|
||||
private[scalive] def render(trackUpdates: Boolean): Option[O] =
|
||||
if !trackUpdates || changed then Some(currentValue)
|
||||
else None
|
||||
|
||||
private[scalive] def setUnchanged(): Unit =
|
||||
changed = false
|
||||
parent.setUnchanged()
|
||||
|
||||
private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue)
|
||||
|
||||
class SplitVar[I, O, Key](
|
||||
parent: Dyn[List[I]],
|
||||
key: I => Key,
|
||||
project: (Key, Dyn[I]) => O):
|
||||
|
||||
// Deleted elements have value none
|
||||
private val memoized: mutable.Map[Key, Option[(Var[I], O)]] =
|
||||
mutable.Map.empty
|
||||
|
||||
private[scalive] def sync(): Unit =
|
||||
parent.sync()
|
||||
if parent.changed then
|
||||
// We keep track of the key to set deleted ones to None
|
||||
val nextKeys = mutable.HashSet.empty[Key]
|
||||
parent.currentValue.foreach(input =>
|
||||
val entryKey = key(input)
|
||||
nextKeys += entryKey
|
||||
memoized.updateWith(entryKey) {
|
||||
// Update matching key
|
||||
case varAndOutput @ Some(Some((entryVar, _))) =>
|
||||
entryVar.set(input)
|
||||
varAndOutput
|
||||
// Create new item
|
||||
case Some(None) | None =>
|
||||
val newVar = Var(input)
|
||||
Some(Some(newVar, project(entryKey, newVar)))
|
||||
}
|
||||
)
|
||||
memoized.keys.foreach(k => if !nextKeys.contains(k) then memoized.update(k, None))
|
||||
|
||||
private[scalive] def render(trackUpdates: Boolean): List[(Key, Option[O])] =
|
||||
memoized.collect {
|
||||
case (k, Some(entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output))
|
||||
case (k, None) => (k, None)
|
||||
}.toList
|
||||
|
||||
private[scalive] def setUnchanged(): Unit =
|
||||
parent.setUnchanged()
|
||||
// Remove previously deleted
|
||||
memoized.filterInPlace((_, v) => v.nonEmpty)
|
||||
|
||||
// Usefull to call setUnchanged when the output is an HtmlElement as only the caller can know the type
|
||||
private[scalive] def callOnEveryChild(f: O => Unit): Unit =
|
||||
memoized.values.foreach(_.foreach((_, output) => f(output)))
|
||||
|
||||
end SplitVar
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package scalive
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
final case class Fingerprint(value: Long, nested: Seq[Fingerprint])
|
||||
|
||||
object Fingerprint:
|
||||
val empty = Fingerprint(0, Seq.empty)
|
||||
def apply(rendered: Rendered): Fingerprint =
|
||||
Fingerprint(
|
||||
rendered.fingerprint,
|
||||
rendered.dynamic.flatMap(render => apply(render(false)))
|
||||
)
|
||||
def apply(comp: Comprehension): Fingerprint = Fingerprint(comp.fingerprint, Seq.empty)
|
||||
def apply(dyn: RenderedDyn): Option[Fingerprint] =
|
||||
dyn match
|
||||
case Some(r: Rendered) => Some(apply(r))
|
||||
case Some(c: Comprehension) => Some(apply(c))
|
||||
case Some(_: String) => None
|
||||
case None => None
|
||||
|
||||
val dynText = digest("text")
|
||||
val dynAttr = digest("attr")
|
||||
val dynAttrValueAsPresence = digest("attrValueAsPresence")
|
||||
val dynWhen = digest("when")
|
||||
val dynSplit = digest("split")
|
||||
|
||||
def digest(s: String): Array[Byte] =
|
||||
MessageDigest.getInstance("MD5").digest(s.getBytes)
|
||||
|
||||
def digest(el: HtmlElement): Array[Byte] =
|
||||
val md = MessageDigest.getInstance("MD5")
|
||||
el.static.foreach(s => md.update(s.getBytes))
|
||||
el.mods.foreach {
|
||||
case Mod.Text(_) => ()
|
||||
case Mod.StaticAttr(_, _) => ()
|
||||
case Mod.StaticAttrValueAsPresence(_, _) => ()
|
||||
case Mod.DynAttr(_, _) => md.update(Fingerprint.dynAttr)
|
||||
case Mod.DynAttrValueAsPresence(_, _) => md.update(Fingerprint.dynAttrValueAsPresence)
|
||||
case Mod.DynText(_) => md.update(Fingerprint.dynText)
|
||||
case Mod.When(_, _) => md.update(Fingerprint.dynWhen)
|
||||
case Mod.Split(_, project) => md.update(digest(project(Dyn.apply)))
|
||||
case Mod.Tag(el) => Right(digest(el))
|
||||
}
|
||||
md.digest()
|
||||
|
||||
def apply(el: HtmlElement): Long = digestToFingerprint(digest(el))
|
||||
|
||||
private def digestToFingerprint(digest: Array[Byte]): Long =
|
||||
ByteBuffer.wrap(digest, 0, 8).getLong
|
||||
|
||||
end Fingerprint
|
||||
|
|
@ -1,28 +1,45 @@
|
|||
package scalive
|
||||
|
||||
import java.io.StringWriter
|
||||
import scalive.Mod.Attr
|
||||
import scalive.Mod.Content
|
||||
|
||||
object HtmlBuilder:
|
||||
|
||||
def build(rendered: Rendered, isRoot: Boolean = false): String =
|
||||
def build(el: HtmlElement, isRoot: Boolean = false): String =
|
||||
val strw = new StringWriter()
|
||||
if isRoot then strw.append("<!doctype html>")
|
||||
build(rendered.static, rendered.dynamic, strw)
|
||||
if isRoot then strw.write("<!doctype html>")
|
||||
build(el.static, el.dynamicMods, strw)
|
||||
strw.toString()
|
||||
|
||||
private def build(
|
||||
static: Seq[String],
|
||||
dynamic: Seq[Boolean => RenderedDyn],
|
||||
dynamic: Seq[(Mod.Attr | Mod.Content) & DynamicMod],
|
||||
strw: StringWriter
|
||||
): Unit =
|
||||
for i <- dynamic.indices do
|
||||
strw.append(static(i))
|
||||
dynamic(i)(false).foreach {
|
||||
case s: String => strw.append(s)
|
||||
case r: Rendered => build(r)
|
||||
case c: Comprehension => build(c, strw)
|
||||
strw.write(static(i))
|
||||
dynamic(i) match
|
||||
case Attr.Dyn(attr, value) =>
|
||||
strw.write(value.render(false).map(attr.codec.encode).getOrElse(""))
|
||||
case Attr.DynValueAsPresence(attr, value) =>
|
||||
strw.write(
|
||||
value.render(false).map(if _ then s" ${attr.name}" else "").getOrElse("")
|
||||
)
|
||||
case Content.Tag(el) => build(el.static, el.dynamicMods, strw)
|
||||
case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse(""))
|
||||
case Content.DynElement(dyn) => ???
|
||||
case Content.DynOptionElement(dyn) =>
|
||||
dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw)))
|
||||
case Content.DynElementColl(dyn) => ???
|
||||
case Content.DynSplit(splitVar) =>
|
||||
val entries = splitVar.render(false)
|
||||
val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static }
|
||||
entries.foreach {
|
||||
case (_, Some(entryEl)) =>
|
||||
build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw)
|
||||
case _ => ()
|
||||
}
|
||||
strw.append(static.last)
|
||||
strw.write(static.last)
|
||||
|
||||
private def build(comp: Comprehension, strw: StringWriter): Unit =
|
||||
comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw))
|
||||
end HtmlBuilder
|
||||
|
|
|
|||
|
|
@ -2,9 +2,27 @@ package scalive
|
|||
|
||||
import scalive.codecs.BooleanAsAttrPresenceCodec
|
||||
import scalive.codecs.Codec
|
||||
import scalive.Mod.Attr
|
||||
import scalive.Mod.Content
|
||||
|
||||
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
|
||||
lazy val static = Rendered.buildStatic(this)
|
||||
def static: Seq[String] = StaticBuilder.build(this)
|
||||
def attrMods: Seq[Mod.Attr] =
|
||||
mods.collect { case mod: Mod.Attr => mod }
|
||||
def contentMods: Seq[Mod.Content] =
|
||||
mods.collect { case mod: Mod.Content => mod }
|
||||
def dynamicMods: Seq[(Mod.Attr | Mod.Content) & DynamicMod] =
|
||||
dynamicAttrMods ++ dynamicContentMods.flatMap {
|
||||
case Content.Tag(el) => el.dynamicMods
|
||||
case mod => List(mod)
|
||||
|
||||
}
|
||||
def dynamicAttrMods: Seq[Mod.Attr & DynamicMod] =
|
||||
mods.collect { case mod: (Mod.Attr & DynamicMod) => mod }
|
||||
def dynamicContentMods: Seq[Mod.Content & DynamicMod] =
|
||||
mods.collect { case mod: (Mod.Content & DynamicMod) => mod }
|
||||
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
|
||||
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
|
||||
|
||||
class HtmlTag(val name: String, val void: Boolean = false):
|
||||
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
|
||||
|
|
@ -12,53 +30,87 @@ class HtmlTag(val name: String, val void: Boolean = false):
|
|||
class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
|
||||
private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
|
||||
|
||||
def :=(value: V): Mod =
|
||||
def :=(value: V): Mod.Attr =
|
||||
if isBooleanAsAttrPresence then
|
||||
Mod.StaticAttrValueAsPresence(
|
||||
Mod.Attr.StaticValueAsPresence(
|
||||
this.asInstanceOf[HtmlAttr[Boolean]],
|
||||
value.asInstanceOf[Boolean]
|
||||
)
|
||||
else Mod.StaticAttr(this, codec.encode(value))
|
||||
else Mod.Attr.Static(this, codec.encode(value))
|
||||
|
||||
def :=(value: Dyn[V]): Mod =
|
||||
def :=(value: Dyn[V]): Mod.Attr =
|
||||
if isBooleanAsAttrPresence then
|
||||
Mod.DynAttrValueAsPresence(
|
||||
Mod.Attr.DynValueAsPresence(
|
||||
this.asInstanceOf[HtmlAttr[Boolean]],
|
||||
value.asInstanceOf[Dyn[Boolean]]
|
||||
)
|
||||
else Mod.DynAttr(this, value)
|
||||
else Mod.Attr.Dyn(this, value)
|
||||
|
||||
enum Mod:
|
||||
case Text(text: String)
|
||||
case StaticAttr(attr: HtmlAttr[?], value: String)
|
||||
case StaticAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean)
|
||||
case DynAttr[T](attr: HtmlAttr[T], value: Dyn[T])
|
||||
case DynAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Dyn[Boolean])
|
||||
case Tag(el: HtmlElement)
|
||||
case DynText(dyn: Dyn[String])
|
||||
case When(dyn: Dyn[Boolean], el: HtmlElement)
|
||||
case Split[T](
|
||||
dynList: Dyn[List[T]],
|
||||
project: Dyn[T] => HtmlElement)
|
||||
sealed trait Mod
|
||||
sealed trait StaticMod extends Mod
|
||||
sealed trait DynamicMod extends Mod
|
||||
|
||||
final case class Dyn[T] private[scalive] (key: LiveState.Key, f: key.Type => T):
|
||||
def render(state: LiveState, trackUpdates: Boolean): Option[T] =
|
||||
val entry = state(key)
|
||||
if !trackUpdates | entry.changed then Some(f(entry.value))
|
||||
else None
|
||||
object Mod:
|
||||
enum Attr extends Mod:
|
||||
case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod
|
||||
case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod
|
||||
case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod
|
||||
case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean])
|
||||
extends Attr
|
||||
with DynamicMod
|
||||
|
||||
def map[T2](f2: T => T2): Dyn[T2] = Dyn(key, f.andThen(f2))
|
||||
enum Content extends Mod:
|
||||
case Text(text: String) extends Content with StaticMod
|
||||
case Tag(el: HtmlElement) extends Content with StaticMod with DynamicMod
|
||||
case DynText(dyn: Dyn[String]) extends Content with DynamicMod
|
||||
case DynElement(dyn: Dyn[HtmlElement]) extends Content with DynamicMod
|
||||
// TODO support arbitrary collection
|
||||
case DynOptionElement(dyn: Dyn[Option[HtmlElement]]) extends Content with DynamicMod
|
||||
case DynElementColl(dyn: Dyn[Iterable[HtmlElement]]) extends Content with DynamicMod
|
||||
case DynSplit(v: SplitVar[?, HtmlElement, ?]) extends Content with DynamicMod
|
||||
|
||||
inline def apply[T2](f2: T => T2): Dyn[T2] = map(f2)
|
||||
extension (mod: Mod)
|
||||
private[scalive] def setAllUnchanged(): Unit =
|
||||
mod match
|
||||
case Attr.Static(_, _) => ()
|
||||
case Attr.StaticValueAsPresence(_, _) => ()
|
||||
case Attr.Dyn(_, value) => value.setUnchanged()
|
||||
case Attr.DynValueAsPresence(attr, value) => value.setUnchanged()
|
||||
case Content.Text(text) => ()
|
||||
case Content.Tag(el) => el.setAllUnchanged()
|
||||
case Content.DynText(dyn) => dyn.setUnchanged()
|
||||
case Content.DynElement(dyn) =>
|
||||
dyn.setUnchanged()
|
||||
dyn.callOnEveryChild(_.setAllUnchanged())
|
||||
case Content.DynOptionElement(dyn) =>
|
||||
dyn.setUnchanged()
|
||||
dyn.callOnEveryChild(_.foreach(_.setAllUnchanged()))
|
||||
case Content.DynElementColl(dyn) =>
|
||||
dyn.setUnchanged()
|
||||
dyn.callOnEveryChild(_.foreach(_.setAllUnchanged()))
|
||||
case Content.DynSplit(v) =>
|
||||
v.setUnchanged()
|
||||
v.callOnEveryChild(_.setAllUnchanged())
|
||||
|
||||
def when(f2: T => Boolean)(el: HtmlElement): Mod.When = Mod.When(map(f2), el)
|
||||
|
||||
inline def whenNot(f2: T => Boolean)(el: HtmlElement): Mod.When =
|
||||
when(f2.andThen(!_))(el)
|
||||
|
||||
extension [T](dyn: Dyn[List[T]])
|
||||
def splitByIndex(project: Dyn[T] => HtmlElement): Mod.Split[T] =
|
||||
Mod.Split(dyn, project)
|
||||
|
||||
object Dyn:
|
||||
def apply[T]: Dyn[T] = Dyn(LiveState.Key[T], identity)
|
||||
private[scalive] def syncAll(): Unit =
|
||||
mod match
|
||||
case Attr.Static(_, _) => ()
|
||||
case Attr.StaticValueAsPresence(_, _) => ()
|
||||
case Attr.Dyn(_, value) => value.sync()
|
||||
case Attr.DynValueAsPresence(attr, value) => value.sync()
|
||||
case Content.Text(text) => ()
|
||||
case Content.Tag(el) => el.syncAll()
|
||||
case Content.DynText(dyn) => dyn.sync()
|
||||
case Content.DynElement(dyn) =>
|
||||
dyn.sync()
|
||||
dyn.callOnEveryChild(_.syncAll())
|
||||
case Content.DynOptionElement(dyn) =>
|
||||
dyn.sync()
|
||||
dyn.callOnEveryChild(_.foreach(_.syncAll()))
|
||||
case Content.DynElementColl(dyn) =>
|
||||
dyn.sync()
|
||||
dyn.callOnEveryChild(_.foreach(_.syncAll()))
|
||||
case Content.DynSplit(v) =>
|
||||
v.sync()
|
||||
v.callOnEveryChild(_.syncAll())
|
||||
end extension
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package scalive
|
||||
|
||||
final case class LiveState private (val data: Map[LiveState.Key, LiveState.Entry[Any]]):
|
||||
def get(k: LiveState.Key): Option[LiveState.Entry[k.Type]] =
|
||||
data.get(k).asInstanceOf[Option[LiveState.Entry[k.Type]]]
|
||||
def set[T](k: Dyn[T], v: T): LiveState =
|
||||
copy(data = data.updated(k.key, LiveState.Entry(true, v)))
|
||||
def apply(k: LiveState.Key): LiveState.Entry[k.Type] =
|
||||
get(k).getOrElse(throw new IllegalArgumentException("An assign of type"))
|
||||
def update(k: LiveState.Key, update: k.Type => k.Type): LiveState =
|
||||
copy(data =
|
||||
data.updatedWith(k)(
|
||||
_.asInstanceOf[Option[LiveState.Entry[k.Type]]]
|
||||
.map(e => LiveState.Entry(true, update(e.value)))
|
||||
)
|
||||
)
|
||||
def remove(k: LiveState.Key): LiveState = copy(data = data - k)
|
||||
def setAllUnchanged: LiveState =
|
||||
LiveState(data.view.mapValues(_.copy(changed = false)).toMap)
|
||||
|
||||
object LiveState:
|
||||
final case class Entry[T](changed: Boolean, value: T)
|
||||
|
||||
val empty = LiveState(Map.empty)
|
||||
|
||||
class Key:
|
||||
type Type
|
||||
def toDyn: Dyn[Type] = Dyn(this, identity)
|
||||
def toDyn[T](f: Type => T): Dyn[T] = Dyn(this, f)
|
||||
object Key:
|
||||
def apply[T] = new Key:
|
||||
type Type = T
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package scalive
|
||||
|
||||
trait LiveView[Cmd]:
|
||||
def mount(state: LiveState): LiveState
|
||||
def handleCommand(cmd: Cmd, state: LiveState): LiveState
|
||||
def render: HtmlElement
|
||||
def handleCommand(cmd: Cmd): Unit
|
||||
val el: HtmlElement
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
package scalive
|
||||
|
||||
import scala.annotation.nowarn
|
||||
import scala.collection.immutable.ArraySeq
|
||||
import scala.collection.mutable.ListBuffer
|
||||
|
||||
type RenderedDyn = Option[String | Rendered | Comprehension]
|
||||
|
||||
final case class Rendered(
|
||||
// val root: Boolean,
|
||||
val fingerprint: Long,
|
||||
val static: Seq[String],
|
||||
val dynamic: Seq[Boolean => RenderedDyn])
|
||||
|
||||
final case class Comprehension(
|
||||
// val hasKey: Boolean,
|
||||
val fingerprint: Long,
|
||||
val static: Seq[String],
|
||||
val entries: Seq[Boolean => Seq[RenderedDyn]])
|
||||
|
||||
object Rendered:
|
||||
|
||||
def render(el: HtmlElement, state: LiveState): Rendered =
|
||||
Rendered(Fingerprint.apply(el), el.static, buildDynamicRendered(el, state))
|
||||
|
||||
def render[T](mod: Mod.DynAttr[T], state: LiveState): Boolean => RenderedDyn =
|
||||
trackUpdates => mod.value.render(state, trackUpdates).map(mod.attr.codec.encode)
|
||||
def render(mod: Mod.DynAttrValueAsPresence, state: LiveState): Boolean => RenderedDyn =
|
||||
trackUpdates =>
|
||||
mod.value.render(state, trackUpdates).map(if _ then s" ${mod.attr.name}" else "")
|
||||
def render(mod: Mod.DynText, state: LiveState): Boolean => RenderedDyn =
|
||||
trackUpdates => mod.dyn.render(state, trackUpdates)
|
||||
def render(mod: Mod.When, state: LiveState): Boolean => RenderedDyn =
|
||||
trackUpdates =>
|
||||
mod.dyn
|
||||
.render(state, trackUpdates)
|
||||
.collect { case true => render(mod.el, state) }
|
||||
def render(mod: Mod.Split[Any], state: LiveState): Boolean => RenderedDyn =
|
||||
trackUpdates =>
|
||||
mod.dynList
|
||||
.render(state, trackUpdates)
|
||||
.collect {
|
||||
case items if items.nonEmpty =>
|
||||
val el = mod.project(Dyn.apply)
|
||||
Comprehension(
|
||||
Fingerprint.apply(el),
|
||||
el.static,
|
||||
items.map(item =>
|
||||
val localDyn = Dyn[Any]
|
||||
val localState = LiveState.empty.set(localDyn, item)
|
||||
val localElem = mod.project(localDyn)
|
||||
trackElemUpdates =>
|
||||
buildDynamicRendered(localElem, localState).map(_(trackElemUpdates))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def buildStatic(el: HtmlElement): ArraySeq[String] =
|
||||
buildStaticFragments(el).flatten.to(ArraySeq)
|
||||
|
||||
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
|
||||
val (attrs, children) = buildStaticFragmentsByType(el)
|
||||
val static = ListBuffer.empty[Option[String]]
|
||||
var staticFragment = s"<${el.tag.name}"
|
||||
for attr <- attrs do
|
||||
attr match
|
||||
case Some(s) =>
|
||||
staticFragment += s
|
||||
case None =>
|
||||
static.append(Some(staticFragment))
|
||||
static.append(None)
|
||||
staticFragment = ""
|
||||
staticFragment += (if el.tag.void then "/>" else ">")
|
||||
for child <- children do
|
||||
child match
|
||||
case Some(s) =>
|
||||
staticFragment += s
|
||||
case None =>
|
||||
static.append(Some(staticFragment))
|
||||
static.append(None)
|
||||
staticFragment = ""
|
||||
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
|
||||
static.append(Some(staticFragment))
|
||||
static.toSeq
|
||||
|
||||
@nowarn("cat=unchecked")
|
||||
private def buildStaticFragmentsByType(el: HtmlElement)
|
||||
: (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
|
||||
val (attrs, children) = el.mods.partitionMap {
|
||||
case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value"""")))
|
||||
case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}")))
|
||||
case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty)
|
||||
case Mod.DynAttr(attr, _) =>
|
||||
Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)))
|
||||
case Mod.DynAttrValueAsPresence(attr, _) =>
|
||||
Left(List(Some(""), None, Some("")))
|
||||
case Mod.Tag(el) => Right(buildStaticFragments(el))
|
||||
case Mod.Text(text) => Right(List(Some(text)))
|
||||
case Mod.DynText(_) => Right(List(None))
|
||||
case Mod.When(_, _) => Right(List(None))
|
||||
case Mod.Split[Any](_, _) => Right(List(None))
|
||||
}
|
||||
(attrs.flatten, children.flatten)
|
||||
|
||||
@nowarn("cat=unchecked")
|
||||
def buildDynamicRendered(
|
||||
el: HtmlElement,
|
||||
state: LiveState
|
||||
): Seq[Boolean => RenderedDyn] =
|
||||
val (attrs, children) = el.mods.partitionMap {
|
||||
case Mod.Text(_) => Right(List.empty)
|
||||
case Mod.StaticAttr(_, _) => Left(List.empty)
|
||||
case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty)
|
||||
case mod: Mod.DynAttr[?] => Right(List(Rendered.render(mod, state)))
|
||||
case mod: Mod.DynAttrValueAsPresence => Right(List(Rendered.render(mod, state)))
|
||||
case Mod.Tag(el) => Right(buildDynamicRendered(el, state))
|
||||
case mod: Mod.DynText => Right(List(Rendered.render(mod, state)))
|
||||
case mod: Mod.When => Right(List(Rendered.render(mod, state)))
|
||||
case mod: Mod.Split[Any] => Right(List(Rendered.render(mod, state)))
|
||||
}
|
||||
attrs.flatten ++ children.flatten
|
||||
end Rendered
|
||||
|
|
@ -3,6 +3,6 @@ import scalive.defs.complex.ComplexHtmlKeys
|
|||
import scalive.defs.tags.HtmlTags
|
||||
|
||||
package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
|
||||
implicit def stringToMod(v: String): Mod = Mod.Text(v)
|
||||
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Tag(el)
|
||||
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.DynText(d)
|
||||
implicit def stringToMod(v: String): Mod = Mod.Content.Text(v)
|
||||
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)
|
||||
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d)
|
||||
|
|
|
|||
|
|
@ -3,18 +3,21 @@ package scalive
|
|||
import zio.json.*
|
||||
|
||||
final case class Socket[Cmd](lv: LiveView[Cmd]):
|
||||
private var state: LiveState = lv.mount(LiveState.empty)
|
||||
private var fingerprint: Fingerprint = Fingerprint.empty
|
||||
|
||||
lv.el.syncAll()
|
||||
|
||||
private var clientInitialized = false
|
||||
val id: String = "scl-123"
|
||||
|
||||
def receiveCommand(cmd: Cmd): Unit =
|
||||
state = lv.handleCommand(cmd, state)
|
||||
lv.handleCommand(cmd)
|
||||
|
||||
def renderHtml: String =
|
||||
HtmlBuilder.build(Rendered.render(lv.render, state), isRoot = true)
|
||||
lv.el.syncAll()
|
||||
HtmlBuilder.build(lv.el, isRoot = true)
|
||||
|
||||
def syncClient: Unit =
|
||||
val r = Rendered.render(lv.render, state)
|
||||
println(DiffBuilder.build(r, fingerprint).toJsonPretty)
|
||||
fingerprint = Fingerprint(r)
|
||||
state = state.setAllUnchanged
|
||||
lv.el.syncAll()
|
||||
println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty)
|
||||
clientInitialized = true
|
||||
lv.el.setAllUnchanged()
|
||||
|
|
|
|||
53
core/src/scalive/StaticBuilder.scala
Normal file
53
core/src/scalive/StaticBuilder.scala
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package scalive
|
||||
|
||||
import scala.collection.immutable.ArraySeq
|
||||
import scala.collection.mutable.ListBuffer
|
||||
import scalive.Mod.Attr
|
||||
import scalive.Mod.Content
|
||||
|
||||
object StaticBuilder:
|
||||
|
||||
def build(el: HtmlElement): ArraySeq[String] =
|
||||
buildStaticFragments(el).flatten.to(ArraySeq)
|
||||
|
||||
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
|
||||
val attrs = el.attrMods.flatMap {
|
||||
case Attr.Static(attr, value) => List(Some(s""" ${attr.name}="$value""""))
|
||||
case Attr.StaticValueAsPresence(attr, value) => List(Some(s" ${attr.name}"))
|
||||
case Attr.Dyn(attr, value) => List(Some(s""" ${attr.name}=""""), None, Some('"'.toString))
|
||||
case Attr.DynValueAsPresence(attr, value) => List(Some(""), None, Some(""))
|
||||
}
|
||||
val children = el.contentMods.flatMap {
|
||||
case Content.Text(text) => List(Some(text))
|
||||
case Content.Tag(el) => buildStaticFragments(el)
|
||||
case Content.DynText(_) => List(None)
|
||||
case Content.DynElement(_) => List(None)
|
||||
case Content.DynOptionElement(_) => List(None)
|
||||
case Content.DynElementColl(_) => List(None)
|
||||
case Content.DynSplit(_) => List(None)
|
||||
}
|
||||
val static = ListBuffer.empty[Option[String]]
|
||||
var staticFragment = s"<${el.tag.name}"
|
||||
for attr <- attrs do
|
||||
attr match
|
||||
case Some(s) =>
|
||||
staticFragment += s
|
||||
case None =>
|
||||
static.append(Some(staticFragment))
|
||||
static.append(None)
|
||||
staticFragment = ""
|
||||
staticFragment += (if el.tag.void then "/>" else ">")
|
||||
for child <- children do
|
||||
child match
|
||||
case Some(s) =>
|
||||
staticFragment += s
|
||||
case None =>
|
||||
static.append(Some(staticFragment))
|
||||
static.append(None)
|
||||
staticFragment = ""
|
||||
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
|
||||
static.append(Some(staticFragment))
|
||||
static.toSeq
|
||||
end buildStaticFragments
|
||||
|
||||
end StaticBuilder
|
||||
|
|
@ -8,326 +8,352 @@ object LiveViewSpec extends TestSuite:
|
|||
|
||||
final case class TestModel(
|
||||
title: String = "title value",
|
||||
otherString: String = "other string value",
|
||||
bool: Boolean = false,
|
||||
nestedTitle: String = "nested title value",
|
||||
cls: String = "text-sm",
|
||||
items: List[NestedModel] = List.empty)
|
||||
final case class NestedModel(name: String, age: Int)
|
||||
final case class UpdateCmd(f: TestModel => TestModel)
|
||||
|
||||
// def assertEqualsJson(actual: Diff, expected: Json) =
|
||||
// assert(actual.toJsonPretty == expected.toJsonPretty)
|
||||
def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) =
|
||||
el.syncAll()
|
||||
val actual = DiffBuilder.build(el, trackUpdates = trackChanges)
|
||||
assert(actual.toJsonPretty == expected.toJsonPretty)
|
||||
|
||||
val emptyDiff = Json.Obj.empty
|
||||
|
||||
val tests = Tests {
|
||||
|
||||
// test("Static only") {
|
||||
// val lv =
|
||||
// LiveView(
|
||||
// new View:
|
||||
// type Model = Unit
|
||||
// val render = div("Static string")
|
||||
// ,
|
||||
// ()
|
||||
// )
|
||||
// test("init") {
|
||||
// assertEqualsJson(
|
||||
// lv.fullDiff,
|
||||
// Json.Obj(
|
||||
// "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff") {
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// test("Dynamic string") {
|
||||
// val lv =
|
||||
// LiveView(
|
||||
// new View:
|
||||
// type Model = TestModel
|
||||
// val render =
|
||||
// div(model(_.title))
|
||||
// ,
|
||||
// TestModel()
|
||||
// )
|
||||
// test("init") {
|
||||
// assertEqualsJson(
|
||||
// lv.fullDiff,
|
||||
// Json
|
||||
// .Obj(
|
||||
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
||||
// "0" -> Json.Str("title value")
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff no update") {
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff with update") {
|
||||
// lv.update(TestModel(title = "title updated"))
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj("0" -> Json.Str("title updated"))
|
||||
// )
|
||||
// }
|
||||
// test("diff with update and no change") {
|
||||
// lv.update(TestModel(title = "title updated"))
|
||||
// lv.update(TestModel(title = "title updated"))
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// test("Dynamic attribute") {
|
||||
// val lv =
|
||||
// LiveView(
|
||||
// new View:
|
||||
// type Model = TestModel
|
||||
// val render =
|
||||
// div(cls := model(_.cls))
|
||||
// ,
|
||||
// TestModel()
|
||||
// )
|
||||
// test("init") {
|
||||
// assertEqualsJson(
|
||||
// lv.fullDiff,
|
||||
// Json
|
||||
// .Obj(
|
||||
// "s" -> Json
|
||||
// .Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
|
||||
// "0" -> Json.Str("text-sm")
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff no update") {
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff with update") {
|
||||
// lv.update(TestModel(cls = "text-md"))
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj("0" -> Json.Str("text-md"))
|
||||
// )
|
||||
// }
|
||||
// test("diff with update and no change") {
|
||||
// lv.update(TestModel(cls = "text-md"))
|
||||
// lv.update(TestModel(cls = "text-md"))
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// test("when mod") {
|
||||
// val lv =
|
||||
// LiveView(
|
||||
// new View:
|
||||
// type Model = TestModel
|
||||
// val render =
|
||||
// div(
|
||||
// model.when(_.bool)(
|
||||
// div("static string", model(_.nestedTitle))
|
||||
// )
|
||||
// )
|
||||
// ,
|
||||
// TestModel()
|
||||
// )
|
||||
// test("init") {
|
||||
// assertEqualsJson(
|
||||
// lv.fullDiff,
|
||||
// Json
|
||||
// .Obj(
|
||||
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
||||
// "0" -> Json.Bool(false)
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff no update") {
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff with unrelated update") {
|
||||
// lv.update(TestModel(title = "title updated"))
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff when true") {
|
||||
// lv.update(TestModel(bool = true))
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "s" -> Json
|
||||
// .Arr(Json.Str("<div>static string"), Json.Str("</div>")),
|
||||
// "0" -> Json.Str("nested title value")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff when nested change") {
|
||||
// lv.update(TestModel(bool = true))
|
||||
// lv.update(TestModel(bool = true, nestedTitle = "nested title updated"))
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "0" -> Json.Str("nested title updated")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// test("splitByIndex mod") {
|
||||
// val initModel =
|
||||
// TestModel(
|
||||
// items = List(
|
||||
// NestedModel("a", 10),
|
||||
// NestedModel("b", 15),
|
||||
// NestedModel("c", 20)
|
||||
// )
|
||||
// )
|
||||
// val lv =
|
||||
// LiveView(
|
||||
// new View:
|
||||
// type Model = TestModel
|
||||
// val render =
|
||||
// div(
|
||||
// ul(
|
||||
// model.splitByIndex(_.items)(elem =>
|
||||
// li(
|
||||
// "Nom: ",
|
||||
// elem(_.name),
|
||||
// " Age: ",
|
||||
// elem(_.age.toString)
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// ,
|
||||
// initModel
|
||||
// )
|
||||
// test("init") {
|
||||
// assertEqualsJson(
|
||||
// lv.fullDiff,
|
||||
// Json
|
||||
// .Obj(
|
||||
// "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
||||
// "0" -> Json.Obj(
|
||||
// "s" -> Json.Arr(
|
||||
// Json.Str("<li>Nom: "),
|
||||
// Json.Str(" Age: "),
|
||||
// Json.Str("</li>")
|
||||
// ),
|
||||
// "d" -> Json.Obj(
|
||||
// "0" -> Json.Obj(
|
||||
// "0" -> Json.Str("a"),
|
||||
// "1" -> Json.Str("10")
|
||||
// ),
|
||||
// "1" -> Json.Obj(
|
||||
// "0" -> Json.Str("b"),
|
||||
// "1" -> Json.Str("15")
|
||||
// ),
|
||||
// "2" -> Json.Obj(
|
||||
// "0" -> Json.Str("c"),
|
||||
// "1" -> Json.Str("20")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff no update") {
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff with unrelated update") {
|
||||
// lv.update(initModel.copy(title = "title updated"))
|
||||
// assertEqualsJson(lv.diff, emptyDiff)
|
||||
// }
|
||||
// test("diff with item changed") {
|
||||
// lv.update(
|
||||
// initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99)))
|
||||
// )
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "d" -> Json.Obj(
|
||||
// "2" -> Json.Obj(
|
||||
// "1" -> Json.Str("99")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff with item added") {
|
||||
// lv.update(
|
||||
// initModel.copy(items = initModel.items.appended(NestedModel("d", 35)))
|
||||
// )
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "d" -> Json.Obj(
|
||||
// "3" -> Json.Obj(
|
||||
// "0" -> Json.Str("d"),
|
||||
// "1" -> Json.Str("35")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff with first item removed") {
|
||||
// lv.update(
|
||||
// initModel.copy(items = initModel.items.tail)
|
||||
// )
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "d" -> Json.Obj(
|
||||
// "0" -> Json.Obj(
|
||||
// "0" -> Json.Str("b"),
|
||||
// "1" -> Json.Str("15")
|
||||
// ),
|
||||
// "1" -> Json.Obj(
|
||||
// "0" -> Json.Str("c"),
|
||||
// "1" -> Json.Str("20")
|
||||
// ),
|
||||
// "2" -> Json.Bool(false)
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// test("diff all removed") {
|
||||
// lv.update(initModel.copy(items = List.empty))
|
||||
// assertEqualsJson(
|
||||
// lv.diff,
|
||||
// Json.Obj(
|
||||
// "0" ->
|
||||
// Json
|
||||
// .Obj(
|
||||
// "d" -> Json.Obj(
|
||||
// "0" -> Json.Bool(false),
|
||||
// "1" -> Json.Bool(false),
|
||||
// "2" -> Json.Bool(false)
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
//
|
||||
// }
|
||||
// }
|
||||
test("Static only") {
|
||||
val lv =
|
||||
new LiveView[Unit]:
|
||||
val el = div("Static string")
|
||||
def handleCommand(cmd: Unit): Unit = ()
|
||||
lv.el.syncAll()
|
||||
|
||||
test("init") {
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
||||
),
|
||||
trackChanges = false
|
||||
)
|
||||
}
|
||||
test("diff") {
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
}
|
||||
|
||||
test("Dynamic string") {
|
||||
val lv =
|
||||
new LiveView[UpdateCmd]:
|
||||
val model = Var(TestModel())
|
||||
val el =
|
||||
div(
|
||||
h1(model(_.title)),
|
||||
p(model(_.otherString))
|
||||
)
|
||||
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||
|
||||
lv.el.syncAll()
|
||||
lv.el.setAllUnchanged()
|
||||
|
||||
test("init") {
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json
|
||||
.Obj(
|
||||
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
|
||||
"0" -> Json.Str("title value"),
|
||||
"1" -> Json.Str("other string value")
|
||||
),
|
||||
trackChanges = false
|
||||
)
|
||||
}
|
||||
test("diff no update") {
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with update") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj("0" -> Json.Str("title updated"))
|
||||
)
|
||||
}
|
||||
test("diff with update and no change") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(title = "title value")))
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with update in multiple commands") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||
lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated")))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json
|
||||
.Obj(
|
||||
"0" -> Json.Str("title updated"),
|
||||
"1" -> Json.Str("other string updated")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("Dynamic attribute") {
|
||||
val lv =
|
||||
new LiveView[UpdateCmd]:
|
||||
val model = Var(TestModel())
|
||||
val el =
|
||||
div(cls := model(_.cls))
|
||||
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||
|
||||
lv.el.syncAll()
|
||||
lv.el.setAllUnchanged()
|
||||
|
||||
test("init") {
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json
|
||||
.Obj(
|
||||
"s" -> Json
|
||||
.Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
|
||||
"0" -> Json.Str("text-sm")
|
||||
),
|
||||
trackChanges = false
|
||||
)
|
||||
}
|
||||
test("diff no update") {
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with update") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(cls = "text-md")))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj("0" -> Json.Str("text-md"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("when mod") {
|
||||
val lv =
|
||||
new LiveView[UpdateCmd]:
|
||||
val model = Var(TestModel())
|
||||
val el =
|
||||
div(
|
||||
model.when(_.bool)(
|
||||
div("static string", model(_.nestedTitle))
|
||||
)
|
||||
)
|
||||
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||
|
||||
lv.el.syncAll()
|
||||
lv.el.setAllUnchanged()
|
||||
|
||||
test("init") {
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json
|
||||
.Obj(
|
||||
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
||||
"0" -> Json.Bool(false)
|
||||
),
|
||||
trackChanges = false
|
||||
)
|
||||
}
|
||||
test("diff no update") {
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with unrelated update") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff when true and nested update") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"s" -> Json
|
||||
.Arr(Json.Str("<div>static string"), Json.Str("</div>")),
|
||||
"0" -> Json.Str("nested title value")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
test("diff when nested change") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
|
||||
lv.el.syncAll()
|
||||
lv.el.setAllUnchanged()
|
||||
lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated")))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"0" -> Json.Str("nested title updated")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("splitByIndex mod") {
|
||||
val initModel = TestModel(
|
||||
items = List(
|
||||
NestedModel("a", 10),
|
||||
NestedModel("b", 15),
|
||||
NestedModel("c", 20)
|
||||
)
|
||||
)
|
||||
val lv =
|
||||
new LiveView[UpdateCmd]:
|
||||
val model = Var(initModel)
|
||||
val el =
|
||||
div(
|
||||
ul(
|
||||
model(_.items).splitByIndex((_, elem) =>
|
||||
li(
|
||||
"Nom: ",
|
||||
elem(_.name),
|
||||
" Age: ",
|
||||
elem(_.age.toString)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||
|
||||
lv.el.syncAll()
|
||||
lv.el.setAllUnchanged()
|
||||
|
||||
test("init") {
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json
|
||||
.Obj(
|
||||
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
||||
"0" -> Json.Obj(
|
||||
"s" -> Json.Arr(
|
||||
Json.Str("<li>Nom: "),
|
||||
Json.Str(" Age: "),
|
||||
Json.Str("</li>")
|
||||
),
|
||||
"d" -> Json.Obj(
|
||||
"0" -> Json.Obj(
|
||||
"0" -> Json.Str("a"),
|
||||
"1" -> Json.Str("10")
|
||||
),
|
||||
"1" -> Json.Obj(
|
||||
"0" -> Json.Str("b"),
|
||||
"1" -> Json.Str("15")
|
||||
),
|
||||
"2" -> Json.Obj(
|
||||
"0" -> Json.Str("c"),
|
||||
"1" -> Json.Str("20")
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
trackChanges = false
|
||||
)
|
||||
}
|
||||
test("diff no update") {
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with unrelated update") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||
assertEqualsDiff(lv.el, emptyDiff)
|
||||
}
|
||||
test("diff with item changed") {
|
||||
lv.handleCommand(
|
||||
UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
|
||||
)
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"d" -> Json.Obj(
|
||||
"2" -> Json.Obj(
|
||||
"1" -> Json.Str("99")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
test("diff with item added") {
|
||||
lv.handleCommand(
|
||||
UpdateCmd(
|
||||
_.copy(items = initModel.items.appended(NestedModel("d", 35)))
|
||||
)
|
||||
)
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"d" -> Json.Obj(
|
||||
"3" -> Json.Obj(
|
||||
"0" -> Json.Str("d"),
|
||||
"1" -> Json.Str("35")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
test("diff with first item removed") {
|
||||
lv.handleCommand(
|
||||
UpdateCmd(
|
||||
_.copy(items = initModel.items.tail)
|
||||
)
|
||||
)
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"d" -> Json.Obj(
|
||||
"0" -> Json.Obj(
|
||||
"0" -> Json.Str("b"),
|
||||
"1" -> Json.Str("15")
|
||||
),
|
||||
"1" -> Json.Obj(
|
||||
"0" -> Json.Str("c"),
|
||||
"1" -> Json.Str("20")
|
||||
),
|
||||
"2" -> Json.Bool(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
test("diff all removed") {
|
||||
lv.handleCommand(UpdateCmd(_.copy(items = List.empty)))
|
||||
assertEqualsDiff(
|
||||
lv.el,
|
||||
Json.Obj(
|
||||
"0" ->
|
||||
Json
|
||||
.Obj(
|
||||
"d" -> Json.Obj(
|
||||
"0" -> Json.Bool(false),
|
||||
"1" -> Json.Bool(false),
|
||||
"2" -> Json.Bool(false)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
end LiveViewSpec
|
||||
|
|
|
|||
|
|
@ -4,24 +4,11 @@ import zio.*
|
|||
|
||||
import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
|
||||
import zio.http.*
|
||||
import zio.http.codec.PathCodec.string
|
||||
import zio.http.template.Html
|
||||
|
||||
object Example extends ZIOAppDefault:
|
||||
|
||||
val s = Socket(
|
||||
TestView,
|
||||
LiveState.empty.set(
|
||||
TestView.model,
|
||||
MyModel(
|
||||
List(
|
||||
NestedModel("a", 10),
|
||||
NestedModel("b", 15),
|
||||
NestedModel("c", 20)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val s = Socket(new TestView())
|
||||
|
||||
val socketApp: WebSocketApp[Any] =
|
||||
Handler.webSocket { channel =>
|
||||
|
|
@ -79,14 +66,26 @@ end Example
|
|||
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
|
||||
final case class NestedModel(name: String, age: Int)
|
||||
|
||||
object TestView extends LiveView:
|
||||
val model = LiveState.Key[MyModel]
|
||||
val render =
|
||||
class TestView extends LiveView[Nothing]:
|
||||
|
||||
val model = Var(
|
||||
MyModel(
|
||||
List(
|
||||
NestedModel("a", 10),
|
||||
NestedModel("b", 15),
|
||||
NestedModel("c", 20)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def handleCommand(cmd: Nothing): Unit = ()
|
||||
|
||||
val el =
|
||||
div(
|
||||
idAttr := "42",
|
||||
cls := model(_.cls),
|
||||
ul(
|
||||
model(_.elems).splitByIndex(elem =>
|
||||
model(_.elems).splitByIndex((_, elem) =>
|
||||
li(
|
||||
"Nom: ",
|
||||
elem(_.name),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue