diff --git a/core/src/scalive/JsonAstBuilder.scala b/core/src/scalive/JsonAstBuilder.scala index ab0673f..f6eb43f 100644 --- a/core/src/scalive/JsonAstBuilder.scala +++ b/core/src/scalive/JsonAstBuilder.scala @@ -21,9 +21,32 @@ object JsonAstBuilder: i.toString -> Json.Str(v.currentValue.toString) case (v: RenderedMod.When[?], i) => if v.displayed then - if includeUnchanged || v.dynCond.wasUpdated then + if includeUnchanged || v.cond.wasUpdated then i.toString -> v.nested.buildInitJson - else i.toString -> buildDiffValue(v.nested.dynamic) + else + i.toString -> buildDiffValue(v.nested.dynamic, includeUnchanged) else i.toString -> Json.Bool(false) + case (v: RenderedMod.Split[?, ?], i) => + i.toString -> + Json + .Obj( + (Option + .when(includeUnchanged)( + "s" -> Json.Arr(v.static.map(Json.Str(_))*) + ) + .toList ++ + List( + "d" -> + Json.Obj( + (v.dynamic.toList.zipWithIndex + .filter(includeUnchanged || _._1.exists(_.wasUpdated)) + .map((mods, i) => + i.toString -> buildDiffValue(mods, includeUnchanged) + ) ++ + v.removedIndexes + .map(i => i.toString -> Json.Bool(false)))* + ) + ))* + ) }* ) diff --git a/core/src/scalive/LiveView.scala b/core/src/scalive/LiveView.scala index 37116b1..37fee7c 100644 --- a/core/src/scalive/LiveView.scala +++ b/core/src/scalive/LiveView.scala @@ -7,11 +7,20 @@ trait LiveView[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[I] = Mod.When(d.andThen(f), tag) + inline def whenNot(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] = when(f.andThen(!_))(tag) + + def splitByIndex[O2](f: O => List[O2])( + project: Dyn[O2, O2] => HtmlTag[O2] + ): Mod.Split[I, O2] = + Mod.Split(d.andThen(f), project) + def run(v: I): O = d(v) + object Dyn: def id[T]: Dyn[T, T] = identity @@ -20,16 +29,22 @@ enum Mod[T]: case Text(text: String) case DynText(dynText: Dyn[T, String]) case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T]) + case Split[T, O]( + dynList: Dyn[T, List[O]], + project: Dyn[O, O] => HtmlTag[O] + ) extends Mod[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 +trait HtmlTag[Model](val name: String): def mods: List[Mod[Model]] -class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]: - val name = "div" +class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("div") +class Ul[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("ul") +class Li[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("li") def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList) +def ul[Model](mods: Mod[Model]*): Ul[Model] = Ul(mods.toList) +def li[Model](mods: Mod[Model]*): Li[Model] = Li(mods.toList) diff --git a/core/src/scalive/LiveViewRenderer.scala b/core/src/scalive/LiveViewRenderer.scala index b92e0c8..74da1f4 100644 --- a/core/src/scalive/LiveViewRenderer.scala +++ b/core/src/scalive/LiveViewRenderer.scala @@ -3,6 +3,10 @@ package scalive import scala.collection.mutable.ListBuffer import scala.collection.immutable.ArraySeq import zio.json.ast.Json +import scala.collection.mutable.ArrayBuffer +import scalive.LiveViewRenderer.buildStatic +import scalive.LiveViewRenderer.buildDynamic +import scala.annotation.nowarn class RenderedLiveView[Model] private[scalive] ( val static: ArraySeq[String], @@ -20,9 +24,10 @@ sealed trait RenderedMod[Model]: object RenderedMod: - class Dynamic[I, O](d: Dyn[I, O], init: I) extends RenderedMod[I]: + class Dynamic[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) + extends RenderedMod[I]: private var value: O = d.run(init) - private var updated: Boolean = false + private var updated: Boolean = startsUpdated def wasUpdated: Boolean = updated def currentValue: O = value def update(v: I): Unit = @@ -33,15 +38,44 @@ object RenderedMod: updated = true class When[Model]( - val dynCond: Dynamic[Model, Boolean], - val nested: RenderedLiveView[Model] + dynCond: Dyn[Model, Boolean], + tag: HtmlTag[Model], + init: Model ) extends RenderedMod[Model]: - def displayed: Boolean = dynCond.currentValue - def wasUpdated: Boolean = dynCond.wasUpdated || nested.wasUpdated + val cond = RenderedMod.Dynamic(dynCond, init) + val nested = LiveViewRenderer.render(tag, init) + def displayed: Boolean = cond.currentValue + def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated def update(model: Model): Unit = - dynCond.update(model) + cond.update(model) nested.update(model) + class Split[Model, Item]( + dynList: Dyn[Model, List[Item]], + project: Dyn[Item, Item] => HtmlTag[Item], + init: Model + ) extends RenderedMod[Model]: + private val tag = project(Dyn.id) + val static: ArraySeq[String] = buildStatic(tag) + val dynamic: ArrayBuffer[ArraySeq[RenderedMod[Item]]] = + dynList.run(init).map(buildDynamic(tag, _)).to(ArrayBuffer) + var removedIndexes: Seq[Int] = Seq.empty + + def wasUpdated: Boolean = + removedIndexes.nonEmpty || dynamic.exists(_.exists(_.wasUpdated)) + + def update(model: Model): Unit = + val items = dynList.run(model) + removedIndexes = + if items.size < dynamic.size then items.size until dynamic.size + else Seq.empty + dynamic.takeInPlace(items.size) + items.zipWithIndex.map((item, i) => + if i >= dynamic.size then + dynamic.append(buildDynamic(tag, item, startsUpdated = true)) + else dynamic(i).foreach(_.update(item)) + ) + object LiveViewRenderer: def render[Model]( @@ -50,46 +84,59 @@ object LiveViewRenderer: ): RenderedLiveView[Model] = render(lv.view, model) - private def render[Model]( + def buildStatic[Model](tag: HtmlTag[Model]): ArraySeq[String] = + buildNestedStatic(tag).flatten.to(ArraySeq) + + private def buildNestedStatic[Model]( + tag: HtmlTag[Model] + ): Seq[Option[String]] = + val static = ListBuffer.empty[Option[String]] + var staticFragment = s"<${tag.name}>" + for mod <- tag.mods.flatMap(buildStatic) do + mod match + case Some(s) => + staticFragment += s + case None => + static.append(Some(staticFragment)) + static.append(None) + staticFragment = "" + staticFragment += s"" + static.append(Some(staticFragment)) + static.toSeq + + def buildStatic[Model](mod: Mod[Model]): Seq[Option[String]] = + mod match + case Mod.Tag(tag) => buildNestedStatic(tag) + case Mod.Text(text) => List(Some(text)) + case Mod.DynText(_) => List(None) + case Mod.When(_, _) => List(None) + case Mod.Split(_, _) => List(None) + + def buildDynamic[Model]( + tag: HtmlTag[Model], + model: Model, + startsUpdated: Boolean = false + ): ArraySeq[RenderedMod[Model]] = + tag.mods.flatMap(buildDynamic(_, model, startsUpdated)).to(ArraySeq) + + @nowarn("cat=unchecked") + def buildDynamic[Model]( + mod: Mod[Model], + model: Model, + startsUpdated: Boolean + ): Seq[RenderedMod[Model]] = + mod match + case Mod.Tag(tag) => buildDynamic(tag, model, startsUpdated) + case Mod.Text(text) => List.empty + case Mod.DynText[Model](dynText) => + List(RenderedMod.Dynamic(dynText, model, startsUpdated)) + case Mod.When[Model](dynCond, tag) => + List(RenderedMod.When(dynCond, tag, model)) + case Mod.Split[Model, Any](dynList, project) => + List(RenderedMod.Split(dynList, project, model)) + + def render[Model]( tag: HtmlTag[Model], model: Model ): RenderedLiveView[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 RenderedLiveView(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"") - - 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) - ) - ) + new RenderedLiveView(buildStatic(tag), buildDynamic(tag, model)) diff --git a/core/src/scalive/main.scala b/core/src/scalive/main.scala index 82c619a..05086f7 100644 --- a/core/src/scalive/main.scala +++ b/core/src/scalive/main.scala @@ -7,27 +7,69 @@ def main = val r = LiveViewRenderer.render( TestLiveView, - MyModel("Initial string", true, "nested init") + MyModel( + List( + NestedModel("a", 10), + NestedModel("b", 15), + NestedModel("c", 20) + ) + ) ) println(r.buildInitJson.toJsonPretty) - r.update(MyModel("Updated string", true, "nested updated")) + println("Edit first and last") + r.update( + MyModel( + List( + NestedModel("x", 10), + NestedModel("b", 15), + NestedModel("c", 99) + ) + ) + ) println(r.buildDiffJson.toJsonPretty) - r.update(MyModel("Updated string", false, "nested updated")) + println("Add one") + r.update( + MyModel( + List( + NestedModel("x", 10), + NestedModel("b", 15), + NestedModel("c", 99), + NestedModel("d", 35) + ) + ) + ) println(r.buildDiffJson.toJsonPretty) - r.update(MyModel("Updated string", true, "nested displayed again")) + println("Remove first") + r.update( + MyModel( + List( + NestedModel("b", 15), + NestedModel("c", 99), + NestedModel("d", 35) + ) + ) + ) println(r.buildDiffJson.toJsonPretty) - r.update(MyModel("Updated string", true, "nested updated")) + println("Remove all") + r.update( + MyModel(List.empty) + ) println(r.buildDiffJson.toJsonPretty) -final case class MyModel(title: String, bool: Boolean, nestedTitle: String) +final case class MyModel(elems: List[NestedModel]) +final case class NestedModel(name: String, age: Int) 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)) + ul( + model.splitByIndex(_.elems)(elem => + li( + "Nom: ", + elem(_.name), + " Age: ", + elem(_.age.toString) + ) + ) ) ) diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index 120e9b9..bda4f8d 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -9,8 +9,10 @@ object LiveViewSpec extends TestSuite: final case class TestModel( title: String = "title value", bool: Boolean = false, - nestedTitle: String = "nested title value" + nestedTitle: String = "nested title value", + items: List[NestedModel] = List.empty ) + final case class NestedModel(name: String, age: Int) def assertEqualsJson(actual: Json, expected: Json) = assert(actual.toJsonPretty == expected.toJsonPretty) @@ -143,15 +145,162 @@ object LiveViewSpec extends TestSuite: ) } } - } -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)) - ) - ) + test("splitByIndex mod") { + val initModel = + TestModel( + items = List( + NestedModel("a", 10), + NestedModel("b", 15), + NestedModel("c", 20) + ) + ) + val lv = + LiveViewRenderer.render( + new LiveView[TestModel]: + val view: HtmlTag[TestModel] = + div( + ul( + model.splitByIndex(_.items)(elem => + li( + "Nom: ", + elem(_.name), + " Age: ", + elem(_.age.toString) + ) + ) + ) + ) + , + initModel + ) + test("init") { + assertEqualsJson( + lv.buildInitJson, + Json + .Obj( + "s" -> Json.Arr(Json.Str("
")), + "0" -> Json.Obj( + "s" -> Json.Arr( + Json.Str("
  • Nom: "), + Json.Str(" Age: "), + Json.Str("
  • ") + ), + "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.buildDiffJson, emptyDiff) + } + test("diff with unrelated update") { + lv.update(initModel.copy(title = "title updated")) + assertEqualsJson(lv.buildDiffJson, emptyDiff) + } + test("diff with item changed") { + lv.update( + initModel.copy(items = + initModel.items.updated(2, NestedModel("c", 99)) + ) + ) + assertEqualsJson( + lv.buildDiffJson, + Json.Obj( + "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.buildDiffJson, + Json.Obj( + "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.buildDiffJson, + Json.Obj( + "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.buildDiffJson, + Json.Obj( + "diff" -> Json.Obj( + "0" -> + Json + .Obj( + "d" -> Json.Obj( + "0" -> Json.Bool(false), + "1" -> Json.Bool(false), + "2" -> Json.Bool(false) + ) + ) + ) + ) + ) + } + } + }