Add splitByIndex

This commit is contained in:
Paul-Henri Froidmont 2025-08-12 01:41:12 +02:00
parent cdadafcfa4
commit 13c15cc289
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
5 changed files with 352 additions and 76 deletions

View file

@ -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)))*
)
))*
)
}*
)

View file

@ -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)

View file

@ -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"</${tag.name}>"
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"</${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)
)
)
new RenderedLiveView(buildStatic(tag), buildDynamic(tag, model))

View file

@ -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)
)
)
)
)

View file

@ -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] =
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(
div("Static string 1"),
model(_.title),
div("Static string 2"),
model.when(_.bool)(
div("maybe rendered", model(_.nestedTitle))
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("<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.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)
)
)
)
)
)
}
}
}