Support message parameters

This commit is contained in:
Paul-Henri Froidmont 2025-09-24 01:40:27 +02:00
parent 763788fb89
commit 681feced9f
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
16 changed files with 277 additions and 106 deletions

View file

@ -27,6 +27,7 @@ class DomDefsGenerator(baseOutputDirectoryPath: String):
override def scalaJsDomImport: String = "" override def scalaJsDomImport: String = ""
override def tagKeysPackagePath: String = "scalive" override def tagKeysPackagePath: String = "scalive"
override def keysPackagePath: String = "scalive" override def keysPackagePath: String = "scalive"
override def generateTagsTrait( override def generateTagsTrait(
tagType: TagType, tagType: TagType,
defGroups: List[(String, List[TagDef])], defGroups: List[(String, List[TagDef])],
@ -186,7 +187,14 @@ class DomDefsGenerator(baseOutputDirectoryPath: String):
val fileContent = generator.generateAttrsTrait( val fileContent = generator.generateAttrsTrait(
defGroups = defGroups.htmlAttrDefGroups.appended( defGroups = defGroups.htmlAttrDefGroups.appended(
"Reflected Attributes" -> ReflectedHtmlAttrDefs.defs.map(_.toAttrDef) "Reflected Attributes" -> ReflectedHtmlAttrDefs.defs
.map(d =>
d.scalaName match
case "defaultChecked" => d.copy(scalaName = "checked")
case "defaultSelected" => d.copy(scalaName = "selected")
case "defaultValue" => d.copy(scalaName = "value")
case _ => d
).map(_.toAttrDef)
), ),
printDefGroupComments = false, printDefGroupComments = false,
traitCommentLines = Nil, traitCommentLines = Nil,

View file

@ -44,10 +44,11 @@ object DiffBuilder:
case Some((entries, keysCount)) => case Some((entries, keysCount)) =>
val static = val static =
entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty)
val includeStatic = !trackUpdates || keysCount == entries.count(_._2.isDefined)
List( List(
Some( Some(
Diff.Comprehension( Diff.Comprehension(
static = if trackUpdates then Seq.empty else static, static = if includeStatic then static else Seq.empty,
entries = entries.map { entries = entries.map {
case (key, Some(el)) => case (key, Some(el)) =>
Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates))
@ -58,7 +59,6 @@ object DiffBuilder:
) )
) )
case None => List(None) case None => List(None)
} }
end DiffBuilder end DiffBuilder

View file

@ -37,14 +37,17 @@ sealed trait Dyn[T]:
private[scalive] def callOnEveryChild(f: T => Unit): Unit private[scalive] def callOnEveryChild(f: T => Unit): Unit
extension [T](parent: Dyn[List[T]]) extension [T](parent: Dyn[List[T]])
def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod = // TODO fix
def splitBy[Key](key: T => Key)(project: (Key, Dyn[T]) => HtmlElement): Mod =
Mod.Content.DynSplit( Mod.Content.DynSplit(
new SplitVar( new SplitVar(
parent.apply(_.zipWithIndex), parent,
key = _._2, key = key,
project = (index, v) => project(index, v(_._1)) project = (k, v) => project(k, v)
) )
) )
def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod =
parent(_.zipWithIndex).splitBy(_._2)((index, v) => project(index, v(_._1)))
private class Var[T] private (initial: T) extends Dyn[T]: private class Var[T] private (initial: T) extends Dyn[T]:
private[scalive] var currentValue: T = initial private[scalive] var currentValue: T = initial
@ -93,7 +96,6 @@ private class SplitVar[I, O, Key](
key: I => Key, key: I => Key,
project: (Key, Dyn[I]) => O): project: (Key, Dyn[I]) => O):
// Deleted elements have value none
private val memoized: mutable.Map[Key, (Var[I], O)] = private val memoized: mutable.Map[Key, (Var[I], O)] =
mutable.Map.empty mutable.Map.empty
@ -141,4 +143,6 @@ private class SplitVar[I, O, Key](
private[scalive] def callOnEveryChild(f: O => Unit): Unit = private[scalive] def callOnEveryChild(f: O => Unit): Unit =
memoized.values.foreach((_, output) => f(output)) memoized.values.foreach((_, output) => f(output))
private[scalive] def currentValues: Iterable[O] = memoized.values.map(_._2)
end SplitVar end SplitVar

View file

@ -1,11 +1,15 @@
package scalive package scalive
import scalive.JSCommands.JSCommand
import scalive.Mod.Attr import scalive.Mod.Attr
import scalive.Mod.Content import scalive.Mod.Content
import scalive.codecs.BooleanAsAttrPresenceEncoder import scalive.codecs.BooleanAsAttrPresenceEncoder
import scalive.codecs.Encoder import scalive.codecs.Encoder
import zio.json.* import zio.json.*
import java.util.Base64
import scala.util.Random
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
def static: Seq[String] = StaticBuilder.build(this) def static: Seq[String] = StaticBuilder.build(this)
def attrMods: Seq[Mod.Attr] = def attrMods: Seq[Mod.Attr] =
@ -25,6 +29,8 @@ class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod)) def prepended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.prependedAll(mod))
def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod)) def apended(mod: Mod*): HtmlElement = HtmlElement(tag, mods.appendedAll(mod))
def findBinding[Msg](id: String): Option[Map[String, String] => Msg] =
mods.iterator.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f }
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll()) private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged()) private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
@ -62,13 +68,30 @@ class HtmlAttr[V](val name: String, val codec: Encoder[V, String]):
) )
else Mod.Attr.Dyn(name, value(codec.encode)) else Mod.Attr.Dyn(name, value(codec.encode))
class HtmlAttrJsonValue(val name: String): class HtmlAttrBinding(val name: String):
def apply(cmd: JSCommand): Mod.Attr =
Mod.Attr.JsBinding(name, cmd.toJson, cmd.bindings)
def :=[V: JsonEncoder](value: V): Mod.Attr = def apply[Msg](msg: Msg): Mod.Attr =
Mod.Attr.Static(name, value.toJson, isJson = true) apply(_ => msg)
def :=[V: JsonEncoder](value: Dyn[V]): Mod.Attr = def apply[Msg](f: Map[String, String] => Msg): Mod.Attr =
Mod.Attr.Dyn(name, value(_.toJson), isJson = true) Mod.Attr.Binding(
name,
Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12)),
f
)
def withValue[Msg](f: String => Msg): Mod.Attr =
apply(m => f(m("value")))
trait BindingAdapter[F, Msg]:
def createMessage(f: F): Map[String, String] => Msg
object BindingAdapter:
given fromString[Msg]: BindingAdapter[String => Msg, Msg] = f => m => f(m("value"))
given fromMap[Msg]: BindingAdapter[Map[String, String] => Msg, Msg] = f => f
final case class BindingParams(params: Map[String, String]):
def apply(key: String) = params.apply(key)
sealed trait Mod sealed trait Mod
sealed trait StaticMod extends Mod sealed trait StaticMod extends Mod
@ -76,8 +99,12 @@ sealed trait DynamicMod extends Mod
object Mod: object Mod:
enum Attr extends Mod: enum Attr extends Mod:
case Static(name: String, value: String, isJson: Boolean = false) extends Attr with StaticMod case Static(name: String, value: String) extends Attr with StaticMod
case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod
case Binding(name: String, id: String, f: Map[String, String] => ?) extends Attr with StaticMod
case JsBinding(name: String, jsonValue: String, bindings: Map[String, ?])
extends Attr
with StaticMod
case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false) case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false)
extends Attr extends Attr
with DynamicMod with DynamicMod
@ -96,7 +123,9 @@ object Mod:
extension (mod: Mod) extension (mod: Mod)
private[scalive] def setAllUnchanged(): Unit = private[scalive] def setAllUnchanged(): Unit =
mod match mod match
case Attr.Static(_, _, _) => () case Attr.Static(_, _) => ()
case Attr.Binding(_, _, _) => ()
case Attr.JsBinding(_, _, _) => ()
case Attr.StaticValueAsPresence(_, _) => () case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value, _) => value.setUnchanged() case Attr.Dyn(_, value, _) => value.setUnchanged()
case Attr.DynValueAsPresence(_, value) => value.setUnchanged() case Attr.DynValueAsPresence(_, value) => value.setUnchanged()
@ -118,8 +147,10 @@ extension (mod: Mod)
private[scalive] def syncAll(): Unit = private[scalive] def syncAll(): Unit =
mod match mod match
case Attr.Static(_, _, _) => () case Attr.Static(_, _) => ()
case Attr.StaticValueAsPresence(_, _) => () case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Binding(_, _, _) => ()
case Attr.JsBinding(_, _, _) => ()
case Attr.Dyn(_, value, _) => value.sync() case Attr.Dyn(_, value, _) => value.sync()
case Attr.DynValueAsPresence(_, value) => value.sync() case Attr.DynValueAsPresence(_, value) => value.sync()
case Content.Text(text) => () case Content.Text(text) => ()
@ -137,4 +168,25 @@ extension (mod: Mod)
case Content.DynSplit(v) => case Content.DynSplit(v) =>
v.sync() v.sync()
v.callOnEveryChild(_.syncAll()) v.callOnEveryChild(_.syncAll())
private[scalive] def findBinding[Msg](id: String): Option[Map[String, String] => Msg] =
mod match
case Attr.Static(_, _) => None
case Attr.StaticValueAsPresence(_, _) => None
case Attr.Binding(_, eventId, f) =>
if id == eventId then Some(f.asInstanceOf[Map[String, String] => Msg])
else None
case Attr.JsBinding(_, _, bindings) =>
bindings.get(id).map(msg => _ => msg.asInstanceOf[Msg])
case Attr.Dyn(_, value, _) => None
case Attr.DynValueAsPresence(_, value) => None
case Content.Text(text) => None
case Content.Tag(el) => el.findBinding(id)
case Content.DynText(dyn) => None
case Content.DynElement(dyn) => dyn.currentValue.findBinding(id)
case Content.DynOptionElement(dyn) => dyn.currentValue.flatMap(_.findBinding(id))
case Content.DynElementColl(dyn) =>
dyn.currentValue.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f }
case Content.DynSplit(v) =>
v.currentValues.iterator.map(_.findBinding(id)).collectFirst { case Some(f) => f }
end extension end extension

View file

@ -3,16 +3,21 @@ package scalive
import zio.json.* import zio.json.*
import zio.json.ast.Json import zio.json.ast.Json
import java.util.Base64
import scala.util.Random
val JS: JSCommands.JSCommand = JSCommands.empty val JS: JSCommands.JSCommand = JSCommands.empty
object JSCommands: object JSCommands:
opaque type JSCommand = List[Json] opaque type JSCommand = List[(Json, Option[Binding[?]])]
final case class Binding[Msg](id: String, msg: Msg)
def empty: JSCommand = List.empty def empty: JSCommand = List.empty
object JSCommand: object JSCommand:
given JsonEncoder[JSCommand] = given JsonEncoder[JSCommand] =
JsonEncoder[Json].contramap(ops => Json.Arr(ops.reverse*)) JsonEncoder[Json].contramap(ops => Json.Arr(ops.map(_._1).reverse*))
private def classNames(names: String): Seq[String] = names.split("\\s+") private def classNames(names: String): Seq[String] = names.split("\\s+")
private def transitionClasses(names: String | (String, String, String)) private def transitionClasses(names: String | (String, String, String))
@ -24,7 +29,15 @@ object JSCommands:
extension (ops: JSCommand) extension (ops: JSCommand)
private def addOp[A: JsonEncoder](kind: String, args: A): JSCommand = private def addOp[A: JsonEncoder](kind: String, args: A): JSCommand =
(kind, args).toJsonAST.fold(e => throw new IllegalArgumentException(e), identity) :: ops (kind, args).toJsonAST.fold(e => throw new IllegalArgumentException(e), (_, None)) :: ops
private def addOp[A: JsonEncoder, Msg](kind: String, args: A, binding: Binding[Msg])
: JSCommand =
(kind, args).toJsonAST
.fold(e => throw new IllegalArgumentException(e), (_, Some(binding))) :: ops
private[scalive] def bindings: Map[String, ?] =
ops.flatMap(_._2).map(b => (b.id, b.msg)).toMap
def addClass = ClassOp("add_class", ops) def addClass = ClassOp("add_class", ops)
def toggleClass = ClassOp("toggle_class", ops) def toggleClass = ClassOp("toggle_class", ops)
@ -100,20 +113,22 @@ object JSCommands:
def popFocus() = def popFocus() =
ops.addOp("pop_focus", Json.Obj.empty) ops.addOp("pop_focus", Json.Obj.empty)
def push[A: JsonEncoder]( def push[Msg](
event: A, event: Msg,
target: String = "", target: String = "",
loading: String = "", loading: String = "",
pageLoading: Boolean = false pageLoading: Boolean = false
) = ) =
val bindingId = Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(12))
ops.addOp( ops.addOp(
"push", "push",
Args.Push( Args.Push(
event.toJson, bindingId,
Option.when(target.nonEmpty)(target), Option.when(target.nonEmpty)(target),
Option.when(loading.nonEmpty)(loading), Option.when(loading.nonEmpty)(loading),
Option.when(!pageLoading)(pageLoading) Option.when(!pageLoading)(pageLoading)
) ),
Binding(bindingId, event)
) )
def pushFocus(to: String = "") = def pushFocus(to: String = "") =

View file

@ -22,8 +22,8 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
new HtmlAttr(s"phx-$suffix", BooleanAsTrueFalseStringEncoder) new HtmlAttr(s"phx-$suffix", BooleanAsTrueFalseStringEncoder)
private def phxAttrInt(suffix: String): HtmlAttr[Int] = private def phxAttrInt(suffix: String): HtmlAttr[Int] =
new HtmlAttr(s"phx-$suffix", IntAsStringEncoder) new HtmlAttr(s"phx-$suffix", IntAsStringEncoder)
private def phxAttrJson(suffix: String): HtmlAttrJsonValue = private def phxAttrBinding(suffix: String): HtmlAttrBinding =
new HtmlAttrJsonValue(s"phx-$suffix") new HtmlAttrBinding(s"phx-$suffix")
private def dataPhxAttr(suffix: String): HtmlAttr[String] = private def dataPhxAttr(suffix: String): HtmlAttr[String] =
dataAttr(s"phx-$suffix") dataAttr(s"phx-$suffix")
@ -33,41 +33,44 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
private[scalive] lazy val linkState = dataPhxAttr("link-state") private[scalive] lazy val linkState = dataPhxAttr("link-state")
// Click // Click
lazy val click = phxAttrJson("click") lazy val onClick = phxAttrBinding("click")
lazy val clickAway = phxAttrJson("click-away") lazy val onClickAway = phxAttrBinding("click-away")
// Focus // Focus
lazy val blur = phxAttrJson("blur") lazy val onBlur = phxAttrBinding("blur")
lazy val focus = phxAttrJson("focus") lazy val onFocus = phxAttrBinding("focus")
lazy val windowBlur = phxAttrJson("window-blur") lazy val onWindowBlur = phxAttrBinding("window-blur")
// Keyboard // Keyboard
lazy val keydown = phxAttrJson("keydown") lazy val onKeydown = phxAttrBinding("keydown")
lazy val keyup = phxAttrJson("keyup") lazy val onKeyup = phxAttrBinding("keyup")
lazy val windowKeydown = phxAttrJson("window-keydown") lazy val onWindowKeydown = phxAttrBinding("window-keydown")
lazy val windowKeyup = phxAttrJson("window-keyup") lazy val onWindowKeyup = phxAttrBinding("window-keyup")
lazy val key = phxAttr("key") // For accepted values, see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
lazy val key = phxAttr("key")
// Scroll // Scroll
lazy val viewportTop = phxAttrJson("viewport-top") lazy val onViewportTop = phxAttrBinding("viewport-top")
lazy val viewportBottom = phxAttrJson("viewport-bottom") lazy val onViewportBottom = phxAttrBinding("viewport-bottom")
// Form // Form
lazy val change = phxAttrJson("change") lazy val onChange = phxAttrBinding("change")
lazy val submit = phxAttrJson("submit") lazy val onSubmit = phxAttrBinding("submit")
lazy val autoRecover = phxAttrJson("auto-recover") lazy val autoRecover = phxAttrBinding("auto-recover")
lazy val triggerAction = phxAttrBool("trigger-action") lazy val triggerAction = phxAttrBool("trigger-action")
// Button // Button
lazy val disableWith = phxAttr("disable-with") lazy val disableWith = phxAttr("disable-with")
// Socket connection lifecycle // Socket connection lifecycle
lazy val connected = phxAttrJson("connected") lazy val onConnected = phxAttrBinding("connected")
lazy val disconnected = phxAttrJson("disconnected") lazy val onDisconnected = phxAttrBinding("disconnected")
// DOM element lifecycle // DOM element lifecycle
lazy val mounted = phxAttrJson("mounted") lazy val onMounted = phxAttrBinding("mounted")
lazy val remove = phxAttrJson("remove") lazy val onRemove = phxAttrBinding("remove")
lazy val update = new HtmlAttr["update" | "stream" | "ignore"](s"phx-update", Encoder(identity)) lazy val onUpdate =
new HtmlAttr["update" | "stream" | "ignore"](s"phx-update", Encoder(identity))
// Client hooks // Client hooks
lazy val hook = phxAttr("hook") lazy val hook = phxAttr("hook")

View file

@ -12,10 +12,10 @@ object StaticBuilder:
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
val attrs = el.attrMods.flatMap { val attrs = el.attrMods.flatMap {
case Attr.Static(name, value, isJson) => case Attr.Static(name, value) => List(Some(s" $name='$value'"))
if isJson then List(Some(s" $name='$value'"))
else List(Some(s""" $name="$value""""))
case Attr.StaticValueAsPresence(name, value) => List(Some(s" $name")) case Attr.StaticValueAsPresence(name, value) => List(Some(s" $name"))
case Attr.Binding(name, id, _) => List(Some(s""" $name="$id""""))
case Attr.JsBinding(name, json, _) => List(Some(s" $name='$json'"))
case Attr.Dyn(name, value, isJson) => case Attr.Dyn(name, value, isJson) =>
if isJson then List(Some(s" $name='"), None, Some("'")) if isJson then List(Some(s" $name='"), None, Some("'"))
else List(Some(s""" $name=""""), None, Some('"'.toString)) else List(Some(s""" $name=""""), None, Some('"'.toString))

View file

@ -15,6 +15,9 @@ lazy val DoubleAsIsEncoder: Encoder[Double, Double] = AsIsEncoder()
lazy val DoubleAsStringEncoder: Encoder[Double, String] = lazy val DoubleAsStringEncoder: Encoder[Double, String] =
Encoder[Double, String](_.toString) Encoder[Double, String](_.toString)
lazy val BooleanAsStringEncoder: Encoder[Boolean, String] =
Encoder[Boolean, String](_.toString)
val BooleanAsIsEncoder: Encoder[Boolean, Boolean] = AsIsEncoder() val BooleanAsIsEncoder: Encoder[Boolean, Boolean] = AsIsEncoder()
lazy val BooleanAsAttrPresenceEncoder: Encoder[Boolean, String] = lazy val BooleanAsAttrPresenceEncoder: Encoder[Boolean, String] =

View file

@ -2,7 +2,6 @@ import CounterLiveView.*
import monocle.syntax.all.* import monocle.syntax.all.*
import scalive.* import scalive.*
import zio.* import zio.*
import zio.json.*
import zio.stream.ZStream import zio.stream.ZStream
class CounterLiveView() extends LiveView[Msg, Model]: class CounterLiveView() extends LiveView[Msg, Model]:
@ -34,8 +33,8 @@ class CounterLiveView() extends LiveView[Msg, Model]:
div( div(
cls := "flex flex-wrap items-center gap-3", cls := "flex flex-wrap items-center gap-3",
button( button(
cls := "btn btn-default", cls := "btn btn-default",
phx.click := Msg.ToggleCounter, phx.onClick(Msg.ToggleCounter),
model(_.isVisible match model(_.isVisible match
case true => "Hide counter" case true => "Hide counter"
case false => "Show counter") case false => "Show counter")
@ -45,8 +44,8 @@ class CounterLiveView() extends LiveView[Msg, Model]:
div( div(
cls := "flex items-center justify-center gap-4", cls := "flex items-center justify-center gap-4",
button( button(
cls := "btn btn-neutral", cls := "btn btn-neutral",
phx.click := Msg.DecCounter, phx.onClick(Msg.DecCounter),
"-" "-"
), ),
div( div(
@ -54,8 +53,8 @@ class CounterLiveView() extends LiveView[Msg, Model]:
model(_.counter.toString) model(_.counter.toString)
), ),
button( button(
cls := "btn btn-neutral", cls := "btn btn-neutral",
phx.click := Msg.IncCounter, phx.onClick(Msg.IncCounter),
"+" "+"
) )
) )
@ -70,7 +69,7 @@ end CounterLiveView
object CounterLiveView: object CounterLiveView:
enum Msg derives JsonCodec: enum Msg:
case ToggleCounter case ToggleCounter
case IncCounter case IncCounter
case DecCounter case DecCounter

View file

@ -38,6 +38,10 @@ object Example extends ZIOAppDefault:
(_, req) => (_, req) =>
val q = req.queryParam("q").map("Param : " ++ _).getOrElse("No param") val q = req.queryParam("q").map("Param : " ++ _).getOrElse("No param")
ListLiveView(q) ListLiveView(q)
),
LiveRoute(
Root / "todo",
(_, _) => TodoLiveView()
) )
) )
) )

View file

@ -5,7 +5,8 @@ import zio.stream.ZStream
class HomeLiveView() extends LiveView[String, Unit]: class HomeLiveView() extends LiveView[String, Unit]:
val links = List( val links = List(
"/counter" -> "Counter", "/counter" -> "Counter",
"/list?q=test" -> "List" "/list?q=test" -> "List",
"/todo" -> "Todo"
) )
def init = ZIO.succeed(()) def init = ZIO.succeed(())

View file

@ -2,7 +2,6 @@ import ListLiveView.*
import monocle.syntax.all.* import monocle.syntax.all.*
import scalive.* import scalive.*
import zio.* import zio.*
import zio.json.*
import zio.stream.ZStream import zio.stream.ZStream
class ListLiveView(someParam: String) extends LiveView[Msg, Model]: class ListLiveView(someParam: String) extends LiveView[Msg, Model]:
@ -48,14 +47,14 @@ class ListLiveView(someParam: String) extends LiveView[Msg, Model]:
div( div(
cls := "card-actions", cls := "card-actions",
button( button(
cls := "btn btn-default", cls := "btn btn-default",
phx.click := Msg.IncAge(1), phx.onClick(Msg.IncAge(1)),
"Inc age" "Inc age"
), ),
span(cls := "grow"), span(cls := "grow"),
button( button(
cls := "btn btn-neutral", cls := "btn btn-neutral",
phx.click := JS.toggleClass("btn-neutral btn-accent").push(Msg.IncAge(-5)), phx.onClick(JS.toggleClass("btn-neutral btn-accent").push(Msg.IncAge(-5))),
"Toggle color" "Toggle color"
) )
) )
@ -68,7 +67,7 @@ end ListLiveView
object ListLiveView: object ListLiveView:
enum Msg derives JsonCodec: enum Msg:
case IncAge(value: Int) case IncAge(value: Int)
final case class Model( final case class Model(

View file

@ -0,0 +1,88 @@
import TodoLiveView.*
import monocle.syntax.all.*
import scalive.*
import zio.*
import zio.stream.ZStream
class TodoLiveView() extends LiveView[Msg, Model]:
def init = ZIO.succeed(Model(List(Todo(99, "some task"))))
def update(model: Model) =
case Msg.Add(text) =>
val nextId = model.todos.maxByOption(_.id).map(_.id).getOrElse(1)
ZIO.succeed(
model
.focus(_.todos)
.modify(_.appended(Todo(nextId, text)))
)
case Msg.Remove(id) =>
ZIO.succeed(
model
.focus(_.todos)
.modify(_.filterNot(_.id == id))
)
case Msg.ToggleCompletion(id) =>
ZIO.succeed(
model
.focus(_.todos)
.modify(
_.map(todo => if todo.id == id then todo.copy(completed = todo.completed) else todo)
)
)
def view(model: Dyn[Model]) =
div(
cls := "mx-auto card bg-base-100 max-w-2xl shadow-xl space-y-6 p-6",
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"))),
input(
cls := "input input-bordered grow",
typ := "text",
nameAttr := "todo-text",
placeholder := "What needs to be done?"
)
),
ul(
cls := "divide-y divide-base-200",
model(_.todos).splitByIndex((_, elem) =>
li(
cls := "py-3 flex flex-wrap items-center justify-between gap-2",
elem(_.text)
)
)
)
)
)
def subscriptions(model: Model) = ZStream.empty
end TodoLiveView
object TodoLiveView:
enum Msg:
case Add(text: String)
case Remove(id: Int)
case ToggleCompletion(id: Int)
final case class Model(todos: List[Todo] = List.empty, filter: Filter = Filter.All)
final case class Todo(id: Int, text: String, completed: Boolean = false)
enum Filter:
case All, Active, Completed

View file

@ -14,10 +14,9 @@ import zio.stream.ZStream
import java.util.Base64 import java.util.Base64
import scala.util.Random import scala.util.Random
final case class LiveRoute[A, Msg: JsonCodec, Model]( final case class LiveRoute[A, Msg, Model](
path: PathCodec[A], path: PathCodec[A],
liveviewBuilder: (A, Request) => LiveView[Msg, Model]): liveviewBuilder: (A, Request) => LiveView[Msg, Model]):
val messageCodec = JsonCodec[Msg]
def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Throwable] = def toZioRoute(rootLayout: HtmlElement => HtmlElement): Route[Any, Throwable] =
Method.GET / path -> handler { (params: A, req: Request) => Method.GET / path -> handler { (params: A, req: Request) =>
@ -44,7 +43,6 @@ final case class LiveRoute[A, Msg: JsonCodec, Model](
) )
) )
} }
end LiveRoute
class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]]): class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]]):
def diffsStream: ZStream[Any, Nothing, (Payload, Meta)] = def diffsStream: ZStream[Any, Nothing, (Payload, Meta)] =
@ -54,7 +52,7 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]
.mergeAllUnbounded()(m.values.map(_.outbox).toList*) .mergeAllUnbounded()(m.values.map(_.outbox).toList*)
).flatMapParSwitch(1)(identity) ).flatMapParSwitch(1)(identity)
def join[Msg: JsonCodec, Model]( def join[Msg, Model](
id: String, id: String,
token: String, token: String,
lv: LiveView[Msg, Model], lv: LiveView[Msg, Model],
@ -86,17 +84,11 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]]
ZIO.logWarning(s"Tried to leave LiveView $id which doesn't exist").as(m) ZIO.logWarning(s"Tried to leave LiveView $id which doesn't exist").as(m)
} }
def event(id: String, value: String, meta: WebSocketMessage.Meta): UIO[Unit] = def event(id: String, event: Payload.Event, meta: WebSocketMessage.Meta): UIO[Unit] =
sockets.get.flatMap { m => sockets.get.flatMap { m =>
m.get(id) match m.get(id) match
case Some(socket) => case Some(socket) =>
socket.inbox socket.inbox.offer(event -> meta).unit
.offer(
value
.fromJson(using socket.messageCodec.decoder)
.getOrElse(throw new IllegalArgumentException())
-> meta
).unit
case None => ZIO.unit case None => ZIO.unit
} }
@ -173,9 +165,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
.map(route.liveviewBuilder(_, req)) .map(route.liveviewBuilder(_, req))
.map( .map(
ZIO.logDebug(s"Joining LiveView ${route.path.toString} ${message.topic}") *> ZIO.logDebug(s"Joining LiveView ${route.path.toString} ${message.topic}") *>
liveChannel.join(message.topic, session, _, message.meta)( liveChannel.join(message.topic, session, _, message.meta)
using route.messageCodec
)
) )
) )
.collectFirst { case Some(join) => join.map(_ => None) } .collectFirst { case Some(join) => join.map(_ => None) }
@ -185,7 +175,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo
liveChannel liveChannel
.leave(message.topic) .leave(message.topic)
.as(Some(message.okReply)) .as(Some(message.okReply))
case Payload.Event(_, event, _) => case event: Payload.Event =>
liveChannel liveChannel
.event(message.topic, event, message.meta) .event(message.topic, event, message.meta)
.map(_ => None) .map(_ => None)

View file

@ -1,23 +1,21 @@
package scalive package scalive
import scalive.WebSocketMessage.LiveResponse import scalive.WebSocketMessage.LiveResponse
import scalive.WebSocketMessage.Payload
import zio.* import zio.*
import zio.Queue import zio.Queue
import zio.json.*
import zio.stream.ZStream
import zio.stream.SubscriptionRef import zio.stream.SubscriptionRef
import scalive.WebSocketMessage.Payload import zio.stream.ZStream
final case class Socket[Msg: JsonCodec, Model] private ( final case class Socket[Msg, Model] private (
id: String, id: String,
token: String, token: String,
inbox: Queue[(Msg, WebSocketMessage.Meta)], inbox: Queue[(Payload.Event, WebSocketMessage.Meta)],
outbox: ZStream[Any, Nothing, (Payload, WebSocketMessage.Meta)], outbox: ZStream[Any, Nothing, (Payload, WebSocketMessage.Meta)],
shutdown: UIO[Unit]): shutdown: UIO[Unit])
val messageCodec = JsonCodec[Msg]
object Socket: object Socket:
def start[Msg: JsonCodec, Model]( def start[Msg, Model](
id: String, id: String,
token: String, token: String,
lv: LiveView[Msg, Model], lv: LiveView[Msg, Model],
@ -25,7 +23,7 @@ object Socket:
): RIO[Scope, Socket[Msg, Model]] = ): RIO[Scope, Socket[Msg, Model]] =
ZIO.logAnnotate("lv", id) { ZIO.logAnnotate("lv", id) {
for for
inbox <- Queue.bounded[(Msg, WebSocketMessage.Meta)](4) inbox <- Queue.bounded[(Payload.Event, WebSocketMessage.Meta)](4)
outHub <- Hub.unbounded[(Payload, WebSocketMessage.Meta)] outHub <- Hub.unbounded[(Payload, WebSocketMessage.Meta)]
initModel <- lv.init initModel <- lv.init
@ -42,10 +40,18 @@ object Socket:
.flatMapParSwitch(1, 1)(identity) .flatMapParSwitch(1, 1)(identity)
.map(_ -> meta.copy(messageRef = None, eventType = "diff")) .map(_ -> meta.copy(messageRef = None, eventType = "diff"))
clientFiber <- clientMsgStream.runForeach { (msg, meta) => clientFiber <- clientMsgStream.runForeach { (event, meta) =>
for for
(modelVar, el) <- ref.get (modelVar, el) <- ref.get
updatedModel <- lv.update(modelVar.currentValue)(msg) f <-
ZIO
.succeed(el.findBinding(event.event))
.someOrFail(
new IllegalArgumentException(
s"No binding found for event ID ${event.event}"
)
)
updatedModel <- lv.update(modelVar.currentValue)(f(event.params))
_ = modelVar.set(updatedModel) _ = modelVar.set(updatedModel)
_ <- lvStreamRef.set(lv.subscriptions(updatedModel)) _ <- lvStreamRef.set(lv.subscriptions(updatedModel))
diff = el.diff() diff = el.diff()

View file

@ -2,8 +2,8 @@ package scalive
import scalive.WebSocketMessage.LiveResponse import scalive.WebSocketMessage.LiveResponse
import scalive.WebSocketMessage.Payload import scalive.WebSocketMessage.Payload
import scalive.WebSocketMessage.Payload.EventType
import zio.Chunk import zio.Chunk
import zio.http.QueryParams
import zio.json.* import zio.json.*
import zio.json.ast.Json import zio.json.ast.Json
@ -87,29 +87,28 @@ object WebSocketMessage:
case Close case Close
case Reply(status: String, response: LiveResponse) case Reply(status: String, response: LiveResponse)
case Diff(diff: scalive.Diff) case Diff(diff: scalive.Diff)
case Event(`type`: Payload.EventType, event: String, value: Map[String, String]) case Event(`type`: String, event: String, value: Json)
object Payload: object Payload:
given JsonCodec[Payload.Join] = JsonCodec.derived given JsonCodec[Payload.Join] = JsonCodec.derived
given JsonEncoder[Payload.Reply] = JsonEncoder.derived given JsonEncoder[Payload.Reply] = JsonEncoder.derived
given JsonCodec[Payload.Event] = JsonCodec.derived given JsonCodec[Payload.Event] = JsonCodec.derived
given JsonEncoder[Payload.Diff] = JsonEncoder[scalive.Diff].contramap(_.diff) given JsonEncoder[Payload.Diff] = JsonEncoder[scalive.Diff].contramap(_.diff)
extension (p: Payload.Event)
def params: Map[String, String] =
p.`type` match
case "form" =>
QueryParams
.decode(
p.value.asString.getOrElse(throw new IllegalArgumentException())
).map.view.mapValues(_.head).toMap
case _ => p.value.as[Map[String, String]].getOrElse(throw new IllegalArgumentException())
def okReply(response: LiveResponse) = def okReply(response: LiveResponse) =
Payload.Reply("ok", response) Payload.Reply("ok", response)
enum EventType:
case Click
object EventType:
given JsonCodec[EventType] = JsonCodec[String].transformOrFail(
{
case "click" => Right(Click)
case s => Left(s"Unsupported event type: $s")
},
{ case Click =>
"click"
}
)
enum LiveResponse: enum LiveResponse:
case Empty case Empty
case InitDiff(rendered: scalive.Diff) case InitDiff(rendered: scalive.Diff)