mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 13:36:59 +01:00
Implement conditional nested tag
This commit is contained in:
parent
0aa10b3114
commit
f0986c1664
5 changed files with 185 additions and 149 deletions
|
|
@ -1,149 +0,0 @@
|
||||||
import scala.collection.immutable.ArraySeq
|
|
||||||
import zio.json.*
|
|
||||||
import zio.json.ast.*
|
|
||||||
import scala.collection.mutable.ListBuffer
|
|
||||||
|
|
||||||
@main
|
|
||||||
def main =
|
|
||||||
val r = TestLiveView.render(MyModel("Initial title"))
|
|
||||||
println(JsonWriter.toJson(r.buildClientStateInit))
|
|
||||||
r.update(MyModel("Updated title"))
|
|
||||||
println(JsonWriter.toJson(r.buildClientStateDiff))
|
|
||||||
r.update(MyModel("Updated title"))
|
|
||||||
println(JsonWriter.toJson(r.buildClientStateDiff))
|
|
||||||
|
|
||||||
trait LiveView[Model]:
|
|
||||||
val model: Dyn[Model, Model] = Dyn.id
|
|
||||||
def view: HtmlTag[Model]
|
|
||||||
|
|
||||||
final case class MyModel(title: String)
|
|
||||||
|
|
||||||
object TestLiveView extends LiveView[MyModel]:
|
|
||||||
val view: HtmlTag[MyModel] =
|
|
||||||
div(
|
|
||||||
div("before"),
|
|
||||||
model(_.title),
|
|
||||||
div("after")
|
|
||||||
)
|
|
||||||
|
|
||||||
object JsonWriter:
|
|
||||||
def toJson(init: ClientState.Init): String =
|
|
||||||
Json
|
|
||||||
.Obj("s" -> Json.Arr(init.static.map(Json.Str(_))*))
|
|
||||||
.merge(
|
|
||||||
Json.Obj(
|
|
||||||
init.dynamic.zipWithIndex.map((v, i) => i.toString -> Json.Str(v))*
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.toJsonPretty
|
|
||||||
def toJson(diff: ClientState.Diff): String =
|
|
||||||
Json
|
|
||||||
.Obj(
|
|
||||||
"diff" ->
|
|
||||||
Json.Obj(
|
|
||||||
diff.dynamic.map((i, v) => i.toString -> Json.Str(v))*
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.toJsonPretty
|
|
||||||
|
|
||||||
class RenderedDyn[I, O](d: Dyn[I, O], init: I):
|
|
||||||
private var value: O = d.run(init)
|
|
||||||
private var updated: Boolean = false
|
|
||||||
|
|
||||||
def wasUpdated: Boolean = updated
|
|
||||||
def currentValue: O = value
|
|
||||||
|
|
||||||
def update(v: I): Unit =
|
|
||||||
val newValue = d.run(v)
|
|
||||||
if value == newValue then updated = false
|
|
||||||
else
|
|
||||||
value = newValue
|
|
||||||
updated = true
|
|
||||||
|
|
||||||
opaque type Dyn[I, O] = I => O
|
|
||||||
extension [I, O](d: Dyn[I, O])
|
|
||||||
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
|
|
||||||
def run(v: I): O = d(v)
|
|
||||||
object Dyn:
|
|
||||||
def id[T]: Dyn[T, T] = identity
|
|
||||||
|
|
||||||
enum Mod[T]:
|
|
||||||
case Tag(v: HtmlTag[T])
|
|
||||||
case Text(v: String)
|
|
||||||
case DynText(v: Dyn[T, String])
|
|
||||||
|
|
||||||
given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_)
|
|
||||||
given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
|
|
||||||
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
|
|
||||||
|
|
||||||
object ClientState:
|
|
||||||
final case class Init(static: Seq[String], dynamic: Seq[String])
|
|
||||||
final case class Diff(dynamic: Seq[(Int, String)])
|
|
||||||
|
|
||||||
extension [Model](lv: LiveView[Model])
|
|
||||||
def render(model: Model): RenderedLiveView[Model] =
|
|
||||||
RenderedLiveView(lv.view, model)
|
|
||||||
|
|
||||||
class RenderedLiveView[Model] private (
|
|
||||||
private val static: ArraySeq[String],
|
|
||||||
private val dynamic: ArraySeq[
|
|
||||||
RenderedDyn[Model, String] // | RenderedLiveView[Model]
|
|
||||||
]
|
|
||||||
):
|
|
||||||
def update(model: Model): Unit =
|
|
||||||
dynamic.foreach(_.update(model))
|
|
||||||
|
|
||||||
def buildClientStateInit: ClientState.Init =
|
|
||||||
ClientState.Init(
|
|
||||||
static,
|
|
||||||
dynamic.map(_.currentValue)
|
|
||||||
)
|
|
||||||
def buildClientStateDiff: ClientState.Diff =
|
|
||||||
ClientState.Diff(
|
|
||||||
dynamic.zipWithIndex.collect {
|
|
||||||
case (dyn, i) if dyn.wasUpdated => i -> dyn.currentValue
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
object RenderedLiveView:
|
|
||||||
def apply[Model](tag: HtmlTag[Model], model: Model) =
|
|
||||||
val static = ListBuffer.empty[String]
|
|
||||||
val dynamic = ListBuffer.empty[RenderedDyn[Model, String]]
|
|
||||||
|
|
||||||
var staticFragment = ""
|
|
||||||
for elem <- buildTag(tag, model) do
|
|
||||||
elem match
|
|
||||||
case s: String =>
|
|
||||||
staticFragment += s
|
|
||||||
case d: Dyn[Model, String] =>
|
|
||||||
static.append(staticFragment)
|
|
||||||
staticFragment = ""
|
|
||||||
dynamic.append(RenderedDyn(d, model))
|
|
||||||
if staticFragment.nonEmpty then static.append(staticFragment)
|
|
||||||
new RenderedLiveView(static.to(ArraySeq), dynamic.to(ArraySeq))
|
|
||||||
|
|
||||||
private def buildTag[Model](
|
|
||||||
tag: HtmlTag[Model],
|
|
||||||
model: Model
|
|
||||||
): List[String | Dyn[Model, String]] =
|
|
||||||
(s"<${tag.name}>"
|
|
||||||
:: tag.mods.flatMap(buildMod(_, model))) :+
|
|
||||||
(s"</${tag.name}>")
|
|
||||||
|
|
||||||
private def buildMod[Model](
|
|
||||||
mod: Mod[Model],
|
|
||||||
model: Model
|
|
||||||
): List[String | Dyn[Model, String]] =
|
|
||||||
mod match
|
|
||||||
case Mod.Tag(v) => buildTag(v, model)
|
|
||||||
case Mod.Text(v) => List(v)
|
|
||||||
case Mod.DynText[Model](v) => List(v)
|
|
||||||
|
|
||||||
trait HtmlTag[Model]:
|
|
||||||
def name: String
|
|
||||||
def mods: List[Mod[Model]]
|
|
||||||
|
|
||||||
class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]:
|
|
||||||
val name = "div"
|
|
||||||
|
|
||||||
def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList)
|
|
||||||
29
core/src/scalive/JsonAstBuilder.scala
Normal file
29
core/src/scalive/JsonAstBuilder.scala
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package scalive
|
||||||
|
|
||||||
|
import zio.json.ast.*
|
||||||
|
|
||||||
|
object JsonAstBuilder:
|
||||||
|
def buildInit(static: Seq[String], dynamic: Seq[RenderedMod[?]]): Json =
|
||||||
|
Json
|
||||||
|
.Obj("s" -> Json.Arr(static.map(Json.Str(_))*))
|
||||||
|
.merge(buildDiffValue(dynamic, includeUnchanged = true))
|
||||||
|
|
||||||
|
def buildDiff(dynamic: Seq[RenderedMod[?]]): Json =
|
||||||
|
Json.Obj("diff" -> buildDiffValue(dynamic))
|
||||||
|
|
||||||
|
private def buildDiffValue(
|
||||||
|
dynamic: Seq[RenderedMod[?]],
|
||||||
|
includeUnchanged: Boolean = false
|
||||||
|
): Json =
|
||||||
|
Json.Obj(
|
||||||
|
dynamic.zipWithIndex.filter(includeUnchanged || _._1.wasUpdated).map {
|
||||||
|
case (v: RenderedMod.Dynamic[?, ?], i) =>
|
||||||
|
i.toString -> Json.Str(v.currentValue.toString)
|
||||||
|
case (v: RenderedMod.When[?], i) =>
|
||||||
|
if v.displayed then
|
||||||
|
if includeUnchanged || v.dynCond.wasUpdated then
|
||||||
|
i.toString -> v.nested.buildInitJson
|
||||||
|
else i.toString -> buildDiffValue(v.nested.dynamic)
|
||||||
|
else i.toString -> Json.Bool(false)
|
||||||
|
}*
|
||||||
|
)
|
||||||
32
core/src/scalive/LiveView.scala
Normal file
32
core/src/scalive/LiveView.scala
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package scalive
|
||||||
|
|
||||||
|
trait LiveView[Model]:
|
||||||
|
val model: Dyn[Model, Model] = Dyn.id
|
||||||
|
def view: HtmlTag[Model]
|
||||||
|
|
||||||
|
opaque type Dyn[I, O] = I => O
|
||||||
|
extension [I, O](d: Dyn[I, O])
|
||||||
|
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
|
||||||
|
def when(f: O => Boolean)(tag: HtmlTag[I]) = Mod.When(d.andThen(f), tag)
|
||||||
|
def run(v: I): O = d(v)
|
||||||
|
object Dyn:
|
||||||
|
def id[T]: Dyn[T, T] = identity
|
||||||
|
|
||||||
|
enum Mod[T]:
|
||||||
|
case Tag(tag: HtmlTag[T])
|
||||||
|
case Text(text: String)
|
||||||
|
case DynText(dynText: Dyn[T, String])
|
||||||
|
case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T])
|
||||||
|
|
||||||
|
given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_)
|
||||||
|
given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
|
||||||
|
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
|
||||||
|
|
||||||
|
trait HtmlTag[Model]:
|
||||||
|
def name: String
|
||||||
|
def mods: List[Mod[Model]]
|
||||||
|
|
||||||
|
class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]:
|
||||||
|
val name = "div"
|
||||||
|
|
||||||
|
def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList)
|
||||||
91
core/src/scalive/LiveViewRenderer.scala
Normal file
91
core/src/scalive/LiveViewRenderer.scala
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package scalive
|
||||||
|
|
||||||
|
import scala.collection.mutable.ListBuffer
|
||||||
|
import scala.collection.immutable.ArraySeq
|
||||||
|
import zio.json.ast.Json
|
||||||
|
|
||||||
|
sealed trait RenderedMod[Model]:
|
||||||
|
def update(model: Model): Unit
|
||||||
|
def wasUpdated: Boolean
|
||||||
|
|
||||||
|
object RenderedMod:
|
||||||
|
class Tag[Model] private[scalive] (
|
||||||
|
val static: ArraySeq[String],
|
||||||
|
val dynamic: ArraySeq[RenderedMod[Model]]
|
||||||
|
) extends RenderedMod[Model]:
|
||||||
|
def update(model: Model): Unit =
|
||||||
|
dynamic.foreach(_.update(model))
|
||||||
|
def wasUpdated: Boolean = dynamic.exists(_.wasUpdated)
|
||||||
|
def buildInitJson: Json = JsonAstBuilder.buildInit(static, dynamic)
|
||||||
|
def buildDiffJson: Json = JsonAstBuilder.buildDiff(dynamic)
|
||||||
|
|
||||||
|
class Dynamic[I, O](d: Dyn[I, O], init: I) extends RenderedMod[I]:
|
||||||
|
private var value: O = d.run(init)
|
||||||
|
private var updated: Boolean = false
|
||||||
|
def wasUpdated: Boolean = updated
|
||||||
|
def currentValue: O = value
|
||||||
|
def update(v: I): Unit =
|
||||||
|
val newValue = d.run(v)
|
||||||
|
if value == newValue then updated = false
|
||||||
|
else
|
||||||
|
value = newValue
|
||||||
|
updated = true
|
||||||
|
|
||||||
|
class When[Model](
|
||||||
|
val dynCond: Dynamic[Model, Boolean],
|
||||||
|
val nested: Tag[Model]
|
||||||
|
) extends RenderedMod[Model]:
|
||||||
|
def displayed: Boolean = dynCond.currentValue
|
||||||
|
def wasUpdated: Boolean = dynCond.wasUpdated || nested.wasUpdated
|
||||||
|
def update(model: Model): Unit =
|
||||||
|
dynCond.update(model)
|
||||||
|
nested.update(model)
|
||||||
|
|
||||||
|
object LiveViewRenderer:
|
||||||
|
|
||||||
|
def render[Model](lv: LiveView[Model], model: Model): RenderedMod.Tag[Model] =
|
||||||
|
render(lv.view, model)
|
||||||
|
|
||||||
|
private def render[Model](
|
||||||
|
tag: HtmlTag[Model],
|
||||||
|
model: Model
|
||||||
|
): RenderedMod.Tag[Model] =
|
||||||
|
val static = ListBuffer.empty[String]
|
||||||
|
val dynamic = ListBuffer.empty[RenderedMod[Model]]
|
||||||
|
|
||||||
|
var staticFragment = ""
|
||||||
|
for elem <- renderTag(tag, model) do
|
||||||
|
elem match
|
||||||
|
case s: String =>
|
||||||
|
staticFragment += s
|
||||||
|
case d: RenderedMod[Model] =>
|
||||||
|
static.append(staticFragment)
|
||||||
|
staticFragment = ""
|
||||||
|
dynamic.append(d)
|
||||||
|
if staticFragment.nonEmpty then static.append(staticFragment)
|
||||||
|
new RenderedMod.Tag(static.to(ArraySeq), dynamic.to(ArraySeq))
|
||||||
|
|
||||||
|
private def renderTag[Model](
|
||||||
|
tag: HtmlTag[Model],
|
||||||
|
model: Model
|
||||||
|
): List[String | RenderedMod[Model]] =
|
||||||
|
(s"<${tag.name}>"
|
||||||
|
:: tag.mods.flatMap(renderMod(_, model))) :+
|
||||||
|
(s"</${tag.name}>")
|
||||||
|
|
||||||
|
private def renderMod[Model](
|
||||||
|
mod: Mod[Model],
|
||||||
|
model: Model
|
||||||
|
): List[String | RenderedMod[Model]] =
|
||||||
|
mod match
|
||||||
|
case Mod.Tag(tag) => renderTag(tag, model)
|
||||||
|
case Mod.Text(text) => List(text)
|
||||||
|
case Mod.DynText[Model](dynText) =>
|
||||||
|
List(RenderedMod.Dynamic(dynText, model))
|
||||||
|
case Mod.When[Model](dynCond, tag) =>
|
||||||
|
List(
|
||||||
|
RenderedMod.When(
|
||||||
|
RenderedMod.Dynamic(dynCond, model),
|
||||||
|
LiveViewRenderer.render(tag, model)
|
||||||
|
)
|
||||||
|
)
|
||||||
33
core/src/scalive/main.scala
Normal file
33
core/src/scalive/main.scala
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package scalive
|
||||||
|
|
||||||
|
import zio.json.*
|
||||||
|
|
||||||
|
@main
|
||||||
|
def main =
|
||||||
|
val r =
|
||||||
|
LiveViewRenderer.render(
|
||||||
|
TestLiveView,
|
||||||
|
MyModel("Initial string", true, "nested init")
|
||||||
|
)
|
||||||
|
println(r.buildInitJson.toJsonPretty)
|
||||||
|
r.update(MyModel("Updated string", true, "nested updated"))
|
||||||
|
println(r.buildDiffJson.toJsonPretty)
|
||||||
|
r.update(MyModel("Updated string", false, "nested updated"))
|
||||||
|
println(r.buildDiffJson.toJsonPretty)
|
||||||
|
r.update(MyModel("Updated string", true, "nested displayed again"))
|
||||||
|
println(r.buildDiffJson.toJsonPretty)
|
||||||
|
r.update(MyModel("Updated string", true, "nested updated"))
|
||||||
|
println(r.buildDiffJson.toJsonPretty)
|
||||||
|
|
||||||
|
final case class MyModel(title: String, bool: Boolean, nestedTitle: String)
|
||||||
|
|
||||||
|
object TestLiveView extends LiveView[MyModel]:
|
||||||
|
val view: HtmlTag[MyModel] =
|
||||||
|
div(
|
||||||
|
div("Static string 1"),
|
||||||
|
model(_.title),
|
||||||
|
div("Static string 2"),
|
||||||
|
model.when(_.bool)(
|
||||||
|
div("maybe rendered", model(_.nestedTitle))
|
||||||
|
)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue