From 1da129f855e438ec70a62df7d57f3840d5f6a949 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Fri, 7 Nov 2025 01:20:14 +0100 Subject: [PATCH] Fix splitById --- core/src/scalive/Diff.scala | 15 +- core/src/scalive/DiffBuilder.scala | 19 ++- core/src/scalive/Dyn.scala | 25 +++- core/src/scalive/HtmlBuilder.scala | 6 +- core/test/src/scalive/LiveViewSpec.scala | 178 +++++++++++++++++++++++ example/src/TodoLiveView.scala | 44 +++--- 6 files changed, 247 insertions(+), 40 deletions(-) diff --git a/core/src/scalive/Diff.scala b/core/src/scalive/Diff.scala index 95ee3fd..5a81afa 100644 --- a/core/src/scalive/Diff.scala +++ b/core/src/scalive/Diff.scala @@ -10,10 +10,10 @@ enum Diff: dynamic: Seq[Diff.Dynamic] = Seq.empty) case Comprehension( static: Seq[String] = Seq.empty, - entries: Seq[Diff.Dynamic] = Seq.empty, + entries: Seq[Diff.Dynamic | Diff.IndexChange] = Seq.empty, count: Int = 0) case Value(value: String) - case Dynamic(key: String, diff: Diff) + case Dynamic(index: Int, diff: Diff) case Deleted extension (diff: Diff) @@ -27,6 +27,8 @@ extension (diff: Diff) object Diff: given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson(_)) + final case class IndexChange(index: Int, previousIndex: Int) + private def toJson(diff: Diff): Json = diff match case Diff.Tag(static, dynamic) => @@ -35,7 +37,7 @@ object Diff: .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) .to(Chunk) .appendedAll( - dynamic.map(d => d.key -> toJson(d.diff)) + dynamic.map(d => d.index.toString -> toJson(d.diff)) ) ) case Diff.Comprehension(static, entries, count) => @@ -47,7 +49,12 @@ object Diff: "k" -> Json .Obj( - entries.map(d => d.key -> toJson(d.diff))* + entries.map { + case Diff.Dynamic(index, diff) => + index.toString -> toJson(diff) + case Diff.IndexChange(index, previousIndex) => + index.toString -> Json.Num(previousIndex) + }* ).add("kc", Json.Num(count)) ) ) diff --git a/core/src/scalive/DiffBuilder.scala b/core/src/scalive/DiffBuilder.scala index c40f691..39636c7 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -17,7 +17,7 @@ object DiffBuilder: static = static, dynamic = buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) => - Diff.Dynamic(index.toString, diff) + Diff.Dynamic(index, diff) } ) @@ -47,15 +47,24 @@ object DiffBuilder: case Some((entries, keysCount, includeStatics)) => val static = if !trackUpdates || includeStatics then - entries.collectFirst { case (_, el) => el.static }.getOrElse(List.empty) + entries.collectFirst { case (_, _, el) => el.static }.getOrElse(List.empty) else List.empty List( Some( Diff.Comprehension( static = static, - entries = entries.map((key, el) => - Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) - ), + entries = entries.map { + case entry @ (previousIndex = None) => + Diff.Dynamic( + entry.index, + build(Seq.empty, entry.value.dynamicMods, trackUpdates) + ) + case (index, Some(previousIndex), _) => + Diff.IndexChange( + index, + previousIndex + ) + }, count = keysCount ) ) diff --git a/core/src/scalive/Dyn.scala b/core/src/scalive/Dyn.scala index 9e540a3..600a251 100644 --- a/core/src/scalive/Dyn.scala +++ b/core/src/scalive/Dyn.scala @@ -99,11 +99,14 @@ private class SplitVar[I, O, Key]( private val memoized: mutable.Map[Key, (Var[I], O)] = mutable.Map.empty + private var previousKeysToIndex: Map[Key, Int] = Map.empty + private var nonEmptySyncCount = 0 private[scalive] def sync(): Unit = parent.sync() if parent.changed then + previousKeysToIndex = memoized.keys.zipWithIndex.toMap // We keep track of the keys to remove deleted ones afterwards val nextKeys = mutable.HashSet.empty[Key] parent.currentValue.foreach(input => @@ -126,15 +129,25 @@ private class SplitVar[I, O, Key]( ) if memoized.nonEmpty then nonEmptySyncCount += 1 - private[scalive] def render(trackUpdates: Boolean) - : Option[(changeList: List[(Int, O)], keysCount: Int, includeStatics: Boolean)] = + private[scalive] def render(trackUpdates: Boolean): Option[ + ( + changeList: List[(index: Int, previousIndex: Option[Int], value: O)], + keysCount: Int, + includeStatics: Boolean + ) + ] = if parent.changed || !trackUpdates then Some( ( - changeList = memoized.values.zipWithIndex.collect { - case ((entryVar, output), index) if !trackUpdates || entryVar.changed => - (index, output) - }.toList, + changeList = memoized.zipWithIndex + .map { case ((key, (entryVar, output)), index) => + (index, previousKeysToIndex.get(key).filterNot(_ == index), entryVar, output) + } + .collect { + case (index, previousIndex, entryVar, output) + if !trackUpdates || entryVar.changed || previousIndex.isDefined => + (index, previousIndex, output) + }.toList, keysCount = memoized.size, includeStatics = nonEmptySyncCount == 1 ) diff --git a/core/src/scalive/HtmlBuilder.scala b/core/src/scalive/HtmlBuilder.scala index eef40b8..e92cdd7 100644 --- a/core/src/scalive/HtmlBuilder.scala +++ b/core/src/scalive/HtmlBuilder.scala @@ -36,10 +36,8 @@ object HtmlBuilder: case Content.DynElementColl(dyn) => ??? case Content.DynSplit(splitVar) => val (entries, _, _) = splitVar.render(false).getOrElse((List.empty, 0, true)) - val staticOpt = entries.collectFirst { case (_, el) => el.static } - entries.foreach((_, entryEl) => - build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw) - ) + val staticOpt = entries.collectFirst { case (value = el) => el.static } + entries.foreach(entry => build(staticOpt.getOrElse(Nil), entry.value.dynamicMods, strw)) strw.write(static.last) end HtmlBuilder diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index a3bc2b5..f17e4f8 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -372,5 +372,183 @@ object LiveViewSpec extends TestSuite: } } + test("splitById mod") { + val initModel = TestModel( + items = List( + NestedModel("a", 10), + NestedModel("b", 15), + NestedModel("c", 20) + ) + ) + val model = Var(initModel) + val el = + div( + ul( + model(_.items).splitBy(_.name)((_, elem) => + li( + "Nom: ", + elem(_.name), + " Age: ", + elem(_.age.toString) + ) + ) + ) + ) + + el.syncAll() + el.setAllUnchanged() + + test("init") { + assertEqualsDiff( + el, + Json + .Obj( + "s" -> Json.Arr(Json.Str("
")), + "0" -> Json.Obj( + "s" -> Json.Arr( + Json.Str("
  • Nom: "), + Json.Str(" Age: "), + Json.Str("
  • ") + ), + "k" -> 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") + ), + "kc" -> Json.Num(3) + ) + ) + ), + trackChanges = false + ) + } + test("diff no update") { + assertEqualsDiff(el, emptyDiff) + } + test("diff with unrelated update") { + model.update(_.copy(title = "title updated")) + assertEqualsDiff(el, emptyDiff) + } + test("diff with item changed") { + model.update(_.copy(items = initModel.items.updated(2, NestedModel("c", 99)))) + assertEqualsDiff( + el, + Json.Obj( + "0" -> + Json + .Obj( + "k" -> Json.Obj( + "2" -> Json.Obj( + "1" -> Json.Str("99") + ), + "kc" -> Json.Num(3) + ) + ) + ) + ) + } + test("diff with item added") { + model.update(_.copy(items = initModel.items.appended(NestedModel("d", 35)))) + assertEqualsDiff( + el, + Json.Obj( + "0" -> + Json + .Obj( + "k" -> Json.Obj( + "3" -> Json.Obj( + "0" -> Json.Str("d"), + "1" -> Json.Str("35") + ), + "kc" -> Json.Num(4) + ) + ) + ) + ) + } + test("diff add one to empty list") { + val model = Var(TestModel(items = List.empty)) + val el = + div( + ul( + model(_.items).splitByIndex((_, elem) => + li( + "Nom: ", + elem(_.name), + " Age: ", + elem(_.age.toString) + ) + ) + ) + ) + el.syncAll() + el.setAllUnchanged() + + model.update(_.copy(items = List(NestedModel("a", 20)))) + + assertEqualsDiff( + el, + Json.Obj( + "0" -> + Json + .Obj( + "s" -> Json.Arr( + Json.Str("
  • Nom: "), + Json.Str(" Age: "), + Json.Str("
  • ") + ), + "k" -> Json.Obj( + "0" -> Json.Obj( + "0" -> Json.Str("a"), + "1" -> Json.Str("20") + ), + "kc" -> Json.Num(1) + ) + ) + ) + ) + } + test("diff with first item removed") { + model.update(_.copy(items = initModel.items.tail)) + assertEqualsDiff( + el, + Json.Obj( + "0" -> + Json + .Obj( + "k" -> Json.Obj( + "0" -> Json.Num(1), + "1" -> Json.Num(2), + "kc" -> Json.Num(2) + ) + ) + ) + ) + } + test("diff all removed") { + model.update(_.copy(items = List.empty)) + assertEqualsDiff( + el, + Json.Obj( + "0" -> + Json + .Obj( + "k" -> Json.Obj( + "kc" -> Json.Num(0) + ) + ) + ) + ) + } + } + } end LiveViewSpec diff --git a/example/src/TodoLiveView.scala b/example/src/TodoLiveView.scala index 621beee..45d6a95 100644 --- a/example/src/TodoLiveView.scala +++ b/example/src/TodoLiveView.scala @@ -10,7 +10,7 @@ class TodoLiveView() extends LiveView[Msg, Model]: def update(model: Model) = case Msg.Add(text) => - val nextId = model.todos.maxByOption(_.id).map(_.id).getOrElse(1) + val nextId = model.todos.maxByOption(_.id).map(_.id).getOrElse(1) + 1 ZIO.succeed( model .focus(_.todos) @@ -37,35 +37,34 @@ class TodoLiveView() extends LiveView[Msg, Model]: div( cls := "card-body", h1(cls := "card-title", "Todos"), - div( - cls := "flex items-center gap-3", - input( - cls := "input input-bordered grow", - typ := "text", - nameAttr := "todo-text", - placeholder := "What needs to be done?", - phx.onKeyup.withValue(Msg.Add(_)), - phx.key := "Enter", - phx.value("test") := "some value" - ) - ), form( cls := "flex items-center gap-3", - phx.onChange(p => Msg.Add(p("todo-text"))), - phx.onSubmit(p => Msg.Add(p("todo-text"))), + phx.onSubmit(p => Msg.Add(p("todo-name"))), input( cls := "input input-bordered grow", typ := "text", - nameAttr := "todo-text", - placeholder := "What needs to be done?" + nameAttr := "todo-name", + placeholder := "What needs to be done?", + value := model(_.inputText) ) ), ul( cls := "divide-y divide-base-200", - model(_.todos).splitByIndex((_, elem) => + model(_.todos).splitBy(_.id)((id, todo) => li( - cls := "py-3 flex flex-wrap items-center justify-between gap-2", - elem(_.text) + cls := "py-3 flex items-center gap-3", + input( + tpe := "checkbox", + cls := "checkbox checkbox-primary", + checked := todo(_.completed), + phx.onClick(Msg.ToggleCompletion(id)) + ), + todo(_.text), + button( + cls := "btn btn-ghost btn-sm text-error", + phx.onClick(Msg.Remove(id)), + "✕" + ) ) ) ) @@ -82,7 +81,10 @@ object TodoLiveView: case Remove(id: Int) case ToggleCompletion(id: Int) - final case class Model(todos: List[Todo] = List.empty, filter: Filter = Filter.All) + final case class Model( + todos: List[Todo] = List.empty, + inputText: String = "", + filter: Filter = Filter.All) final case class Todo(id: Int, text: String, completed: Boolean = false) enum Filter: case All, Active, Completed