Fix splitById

This commit is contained in:
Paul-Henri Froidmont 2025-11-07 01:20:14 +01:00
parent 21309629bc
commit 1da129f855
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
6 changed files with 247 additions and 40 deletions

View file

@ -10,10 +10,10 @@ enum Diff:
dynamic: Seq[Diff.Dynamic] = Seq.empty) dynamic: Seq[Diff.Dynamic] = Seq.empty)
case Comprehension( case Comprehension(
static: Seq[String] = Seq.empty, static: Seq[String] = Seq.empty,
entries: Seq[Diff.Dynamic] = Seq.empty, entries: Seq[Diff.Dynamic | Diff.IndexChange] = Seq.empty,
count: Int = 0) count: Int = 0)
case Value(value: String) case Value(value: String)
case Dynamic(key: String, diff: Diff) case Dynamic(index: Int, diff: Diff)
case Deleted case Deleted
extension (diff: Diff) extension (diff: Diff)
@ -27,6 +27,8 @@ extension (diff: Diff)
object Diff: object Diff:
given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson(_)) given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson(_))
final case class IndexChange(index: Int, previousIndex: Int)
private def toJson(diff: Diff): Json = private def toJson(diff: Diff): Json =
diff match diff match
case Diff.Tag(static, dynamic) => case Diff.Tag(static, dynamic) =>
@ -35,7 +37,7 @@ object Diff:
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk) .to(Chunk)
.appendedAll( .appendedAll(
dynamic.map(d => d.key -> toJson(d.diff)) dynamic.map(d => d.index.toString -> toJson(d.diff))
) )
) )
case Diff.Comprehension(static, entries, count) => case Diff.Comprehension(static, entries, count) =>
@ -47,7 +49,12 @@ object Diff:
"k" -> "k" ->
Json Json
.Obj( .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)) ).add("kc", Json.Num(count))
) )
) )

View file

@ -17,7 +17,7 @@ object DiffBuilder:
static = static, static = static,
dynamic = dynamic =
buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) => 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)) => case Some((entries, keysCount, includeStatics)) =>
val static = val static =
if !trackUpdates || includeStatics then 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 else List.empty
List( List(
Some( Some(
Diff.Comprehension( Diff.Comprehension(
static = static, static = static,
entries = entries.map((key, el) => entries = entries.map {
Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) 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 count = keysCount
) )
) )

View file

@ -99,11 +99,14 @@ private class SplitVar[I, O, Key](
private val memoized: mutable.Map[Key, (Var[I], O)] = private val memoized: mutable.Map[Key, (Var[I], O)] =
mutable.Map.empty mutable.Map.empty
private var previousKeysToIndex: Map[Key, Int] = Map.empty
private var nonEmptySyncCount = 0 private var nonEmptySyncCount = 0
private[scalive] def sync(): Unit = private[scalive] def sync(): Unit =
parent.sync() parent.sync()
if parent.changed then if parent.changed then
previousKeysToIndex = memoized.keys.zipWithIndex.toMap
// We keep track of the keys to remove deleted ones afterwards // We keep track of the keys to remove deleted ones afterwards
val nextKeys = mutable.HashSet.empty[Key] val nextKeys = mutable.HashSet.empty[Key]
parent.currentValue.foreach(input => parent.currentValue.foreach(input =>
@ -126,15 +129,25 @@ private class SplitVar[I, O, Key](
) )
if memoized.nonEmpty then nonEmptySyncCount += 1 if memoized.nonEmpty then nonEmptySyncCount += 1
private[scalive] def render(trackUpdates: Boolean) private[scalive] def render(trackUpdates: Boolean): Option[
: Option[(changeList: List[(Int, O)], keysCount: Int, includeStatics: Boolean)] = (
changeList: List[(index: Int, previousIndex: Option[Int], value: O)],
keysCount: Int,
includeStatics: Boolean
)
] =
if parent.changed || !trackUpdates then if parent.changed || !trackUpdates then
Some( Some(
( (
changeList = memoized.values.zipWithIndex.collect { changeList = memoized.zipWithIndex
case ((entryVar, output), index) if !trackUpdates || entryVar.changed => .map { case ((key, (entryVar, output)), index) =>
(index, output) (index, previousKeysToIndex.get(key).filterNot(_ == index), entryVar, output)
}.toList, }
.collect {
case (index, previousIndex, entryVar, output)
if !trackUpdates || entryVar.changed || previousIndex.isDefined =>
(index, previousIndex, output)
}.toList,
keysCount = memoized.size, keysCount = memoized.size,
includeStatics = nonEmptySyncCount == 1 includeStatics = nonEmptySyncCount == 1
) )

View file

@ -36,10 +36,8 @@ object HtmlBuilder:
case Content.DynElementColl(dyn) => ??? case Content.DynElementColl(dyn) => ???
case Content.DynSplit(splitVar) => case Content.DynSplit(splitVar) =>
val (entries, _, _) = splitVar.render(false).getOrElse((List.empty, 0, true)) val (entries, _, _) = splitVar.render(false).getOrElse((List.empty, 0, true))
val staticOpt = entries.collectFirst { case (_, el) => el.static } val staticOpt = entries.collectFirst { case (value = el) => el.static }
entries.foreach((_, entryEl) => entries.foreach(entry => build(staticOpt.getOrElse(Nil), entry.value.dynamicMods, strw))
build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw)
)
strw.write(static.last) strw.write(static.last)
end HtmlBuilder end HtmlBuilder

View file

@ -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("<div><ul>"), Json.Str("</ul></div>")),
"0" -> Json.Obj(
"s" -> Json.Arr(
Json.Str("<li>Nom: "),
Json.Str(" Age: "),
Json.Str("</li>")
),
"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("<li>Nom: "),
Json.Str(" Age: "),
Json.Str("</li>")
),
"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 end LiveViewSpec

View file

@ -10,7 +10,7 @@ class TodoLiveView() extends LiveView[Msg, Model]:
def update(model: Model) = def update(model: Model) =
case Msg.Add(text) => 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( ZIO.succeed(
model model
.focus(_.todos) .focus(_.todos)
@ -37,35 +37,34 @@ class TodoLiveView() extends LiveView[Msg, Model]:
div( div(
cls := "card-body", cls := "card-body",
h1(cls := "card-title", "Todos"), 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( form(
cls := "flex items-center gap-3", cls := "flex items-center gap-3",
phx.onChange(p => Msg.Add(p("todo-text"))), phx.onSubmit(p => Msg.Add(p("todo-name"))),
phx.onSubmit(p => Msg.Add(p("todo-text"))),
input( input(
cls := "input input-bordered grow", cls := "input input-bordered grow",
typ := "text", typ := "text",
nameAttr := "todo-text", nameAttr := "todo-name",
placeholder := "What needs to be done?" placeholder := "What needs to be done?",
value := model(_.inputText)
) )
), ),
ul( ul(
cls := "divide-y divide-base-200", cls := "divide-y divide-base-200",
model(_.todos).splitByIndex((_, elem) => model(_.todos).splitBy(_.id)((id, todo) =>
li( li(
cls := "py-3 flex flex-wrap items-center justify-between gap-2", cls := "py-3 flex items-center gap-3",
elem(_.text) 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 Remove(id: Int)
case ToggleCompletion(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) final case class Todo(id: Int, text: String, completed: Boolean = false)
enum Filter: enum Filter:
case All, Active, Completed case All, Active, Completed