All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.liftmodules.ng.NgModelBinder.scala Maven / Gradle / Ivy

package net.liftmodules.ng

import Angular.NgModel
import js.JsonDeltaFuncs._
import net.liftweb._
import net.liftweb.json.{DefaultFormats, Formats, JsonParser, parse}
import json.Serialization._
import json.JsonAST._
import common._
import http.SHtml._
import http.js._
import http.S
import JE._
import net.liftweb.http.js.JsCmds._
import scala.xml.NodeSeq
import net.liftweb.http.js.JE.JsEq
import net.liftweb.http.js.JE.JsNotEq
import net.liftweb.json.JsonAST.JString
import net.liftweb.http.js.JsCmds.JsCrVar
import net.liftweb.common.Full
import net.liftweb.http.js.JE.JsVar
import net.liftweb.http.js.JE.JsRaw
import net.liftweb.http.js.JE.Call
import net.liftweb.http.js.JsCmds.SetExp
import net.liftweb.json.JsonAST.JInt

/**
 * Simple binding actor for creating a binding actor in one line
 *
 * @param bindTo The client `\$scope` element to bind to
 * @param initialValue Initial value on session initialization
 * @param onClientUpdate Callback to execute on each update from the client
 * @param clientSendDelay Milliseconds for the client to delay sending updates, allowing them to batch into one request. Defaults to 1 second (1000)
 * @tparam M The type of the model to be used in this actor
 */
abstract class SimpleNgModelBinder[M <: NgModel : Manifest] (
  val bindTo:String,
  val initialValue:M,
  override val onClientUpdate:M=>M = { m:M => m },
  override val clientSendDelay:Int = 1000,
  override val formats:Formats = DefaultFormats
) extends NgModelBinder[M]{
  direction:BindDirection =>
}

private [ng] sealed trait BindingBase {
  /** The client `\$scope` element to bind to */
  def bindTo: String

  def formats:Formats

  /** The server-side state of the binder */
  private [ng] var stateJson: JValue
  /** Builds the $JsCmd to mutate the client state on server update */
  private [ng] def buildMutator(id:String, newState:JValue):JsCmd =
    JsCrVar("m", newState) &  // Name the state 'm'
    Call("e('"+id+"').injector().get('plumbing').inject", JsVar("m")) & // Inject promises
    SetExp(JsVar("s('"+id+"')." + bindTo), JsVar("m")) // Update the scope
  /** Builds the client-side update variable to send to the server on client-side update */
  private [ng] def buildClientUpdateVar:JsCmd = JsCrVar("u", Call("JSON.stringify", JsVar("n")))
  /** Converts the $JValue sent from the client to the server into the respective $NgModel */
  private [ng] def jValueToState[M <: NgModel](update:JValue, current:M)(implicit mf:Manifest[M]):M = {
    update.extract(formats, mf)
  }

  private [ng] def retainState:Boolean = false
}

/** Base trait for the two binding direction mixins */
sealed trait BindDirection {
  private [ng] def toClient = false
  private [ng] def toServer = false
}
/** Mix with your NgModelBinder to bind state from the server to the client */
trait BindingToClient extends BindDirection {
  private [ng] override val toClient = true
}
/** Mix with your NgModelBinder to bind state from the client to the server */
trait BindingToServer extends BindDirection {
  private [ng] override val toServer = true
}

/** Base trait for the scope of binding, either request (i.e. per page load) or session.  Default is request */
sealed trait BindingScope extends BindingBase {
  private [ng] def sessionScope = false
}
/** Mix with your NgModelBinder to extend the scope of the binder to the entire user session */
trait SessionScope extends BindingScope {
  private [ng] override def sessionScope = true
  private [ng] override def retainState = true
}

/** Mix with your NgModelBinder to optimize the binding and attempt to send the least amount of data needed.  Regard this as incomplete and experimental. */
trait BindingOptimizations extends BindingBase {
  private [ng] override def buildMutator(id:String, newState: JValue) = {
    val diff = stateJson dfn newState
    diff(JsVar("s('"+id+"')." + bindTo)) // Send the diff
  }
  private [ng] override def buildClientUpdateVar:JsCmd = JsCrVar("u", Call("JSON.stringify", JsVar("{add:n}")))
  private [ng] override def jValueToState[M <: NgModel](update:JValue, current:M)(implicit mf:Manifest[M]):M = {
    import js.ToWithExtractMerged
    val added = Json.slash(update, "add")
    added.extractMerged(current)(mf, formats)
  }

  private [ng] override def retainState = true
}

/**
  * CometActor which implements binding to a model in the target \$scope.
  * While a trait would be preferable, we need the type constraint in order
  * for lift-json to deserialize messages from the client.
  * @tparam M The type of the model to be used in this actor
  */
abstract class NgModelBinder[M <: NgModel : Manifest] extends AngularActor with BindingBase with BindingScope {
  self:BindDirection  =>
  import Angular._

  private [ng] def buildMutator(newState:JValue):JsCmd = buildMutator(id, newState)
  private [ng] val jsScope = "s('"+id+"')"

  /** Initial value on session initialization */
  def initialValue: M

  /** Milliseconds for the client to delay sending updates, allowing them to batch into one request */
  def clientSendDelay: Int = 1000

  /** Callback to execute on each update from the client */
  def onClientUpdate: M => M = {m:M => m}

  // This must be lazy so that it is invoked only after name is set.
  private lazy val guts =
    if(toServer && toClient && sessionScope)
      if(name.isDefined) new TwoWaySessionNamed else new TwoWaySessionUnnamed
    else if(toServer && toClient && !sessionScope)
      new TwoWayRequestScoped
    else if(toClient)
      new ToClientGuts
    else
      new ToServerGuts

  override def fixedRender = nodesToRender ++ guts.render

  override def lowPriority = guts.receive

  /** Abstracting the guts of our actor. */
  private[ng] trait BindingGuts {
    def receive: PartialFunction[Any, Unit]

    def render: NodeSeq

  }

  /** Called after an update from Client.  Input is the client ID where the update originated from */
  private type AfterUpdateFn = Box[String] => Unit
  /** Called to send a JsCmd to the client */
  private type SendCmdFn = JsCmd => Unit
  /** Called to handle JSON from the client */
  private type JsonHandlerFn = String => Unit

  private class ToServerGuts extends BindingGuts with ScalaUtils {
    override def render = Script(buildCmd(root = false,
      renderCurrentState &
      renderThrottleCount &
      watch(timeThrottledCall(sendToServer(handleJson)))
      // TODO: Figure out how to ignore initial $watch
    ))

    private def handleJson:JsonHandlerFn = { json =>
      fromClient(json, Empty, afterUpdate)
    }

    override def receive = empty_pf
    
    private def afterUpdate:AfterUpdateFn = id => {}
  }

  private class ToClientGuts extends BindingGuts {
    override def render = Script(buildCmd(root = false, renderCurrentState))

    override def receive = receiveFromServer(sendToClient) orElse receiveToClient

    private def sendToClient:SendCmdFn = cmd => self ! ToClient(cmd)
  }

  private class TwoWayRequestScoped extends BindingGuts {
    override def render = Script(buildCmd(root = false,
      renderCurrentState &
      renderThrottleCount &
      SetExp(JsVar(jsScope+"." + lastServerVal + bindTo), JsVar(jsScope+"." + bindTo)) & // This prevents us from sending a server-sent value back to the server when doing 2-way binding
      watch(ifNotServerEcho(timeThrottledCall(sendToServer(handleJson))))
    ))

    private def handleJson:JsonHandlerFn = { json =>
      fromClient(json, Empty, afterUpdate)
    }

    private def afterUpdate:AfterUpdateFn = id => {}

    override def receive = receiveFromServer(sendToClient) orElse receiveToClient

    private def sendToClient:SendCmdFn = cmd => self ! ToClient(cmd)
  }

  /** Guts for the unnamed binding actor which exits per session and allows the models to be bound together */
  private class TwoWaySessionUnnamed extends BindingGuts {

    override def render = Script(buildCmd(root = false,
      renderCurrentState &
      renderThrottleCount &
      SetExp(JsVar(jsScope+"." + lastServerVal + bindTo), JsVar(jsScope+"." + bindTo)) // This prevents us from sending a server-sent value back to the server when doing 2-way binding
    ))

    override def receive = receiveFromServer(sendToClient(Empty)) orElse receiveFromClient(afterUpdate)

    private def sendToClient(exclude:Box[String]):SendCmdFn = { cmd =>
      for {
        t <- theType
        session <- S.session
        comet <- session.findComet(t)
        if comet.name.isDefined  // Never send to unnamed comet. It doesn't handle these messages.
        if comet.name != exclude // Send to all clients but the originating client (if not Empty)
      } { comet ! ToClient(cmd) }

      // If we don't poke, then next time we are rendered, it won't contain the latest state
      poke()
    }

    private def afterUpdate(exclude:Box[String]): Unit = {
      val cmd = buildMutator(stateJson) &
        SetExp(JsVar(jsScope+"." + lastServerVal + bindTo), JsVar(jsScope+"." + bindTo)) // And remember what we sent so we can ignore it later

      sendToClient(exclude)(cmd)
    }
  }

  /** Guts for the named binding actor which exists per page and facilitates models to a given rendering of the page */
  private class TwoWaySessionNamed extends BindingGuts {
    override def render = Script(buildCmd(root = false, watch(ifNotServerEcho(timeThrottledCall(sendToServer(sendToSession))))))

    override def receive = receiveToClient

    private def sendToSession:JsonHandlerFn = json => for {
      session <- S.session
      cometType <- theType
      comet <- session.findComet(cometType, Empty)
      clientId <- name
    } { comet ! FromClient(json, Full(clientId)) }
  }
  
  private val lastServerVal = "net_liftmodules_ng_last_val_"
  private val queueCount = "net_liftmodules_ng_queue_count_"

  private var stateModel: M = initialValue
  private [ng] var stateJson = toJValue(stateModel)

  private def receiveFromClient(afterUpdate: AfterUpdateFn): PartialFunction[Any, Unit] = {
    case FromClient(json, id) => fromClient(json, id, afterUpdate)
  }

  private def receiveFromServer(sendFn: SendCmdFn): PartialFunction[Any, Unit] = {
    case m: M => fromServer(m, sendFn)
  }

  private def receiveToClient: PartialFunction[Any, Unit] = {
    case ToClient(cmd) => partialUpdate(buildCmd(root = false, cmd))
  }

  private def fromServer(m: M, sendFn: SendCmdFn) = {
    val mJs = toJValue(m)
    val cmd = buildMutator(mJs)
    sendFn(cmd)
    if(retainState) {
      stateJson = mJs
      stateModel = m
    }
  }

  private def fromClient(json: String, clientId:Box[String], afterUpdate: AfterUpdateFn) = {
    val parsed = JsonParser.parse(json)
    val updated = jValueToState(parsed, stateModel)
    logger.debug("From Client: " + updated)

    // TODO: Do something with the return value, or change it to return unit?
    onClientUpdate(updated)

    if(retainState) {
      // TODO: When jUpdate becomes a diff, make sure we have the whole thing
      stateModel = updated
      stateJson = toJValue(updated)
    }

    afterUpdate(clientId)
  }

  private def toJValue(m: M): JValue = {
    m match {
      case m: NgModel if m != null => parse(stringify(m)(formats))
      case e => JNull
    }
  }

  private def renderCurrentState = SetExp(JsVar(jsScope+"." + bindTo), stateJson) & // Send the current state with the page
    Call("e('"+id+"').injector().get('plumbing').inject", JsVar(jsScope+"." + bindTo)) // Inject any promises we're sending
  private val renderThrottleCount = SetExp(JsVar(jsScope+"." + queueCount + bindTo), JInt(0)) // Set the last server val to avoid echoing it back

  private def watch(f:JsCmd):JsCmd = Call(jsScope+".$watch", JString(bindTo), AnonFunc("n,o", f), JsTrue) // True => Deep comparison

  private def ifNotServerEcho(f:JsCmd):JsCmd =
  // If the new value, n, is not equal to the last server val, send it.
    JsIf(JsNotEq(JsVar("n"), JsRaw(jsScope+"." + lastServerVal + bindTo)),
      f,
      // else remove our last saved value so we can forget about it
      SetExp(JsVar(jsScope+"." + lastServerVal + bindTo), JsNull)
    )

  private def timeThrottledCall(f:JsCmd):JsCmd =
    JsCrVar("c", JsVar(jsScope + "." + queueCount + bindTo + "++")) &
      Call("setTimeout", AnonFunc(
        JsIf(JsEq(JsVar("c+1"), JsVar(jsScope + "." + queueCount + bindTo)), f)
      ), JInt(clientSendDelay))

  private def sendToServer(handler: JsonHandlerFn):JsCmd = buildClientUpdateVar &
    ajaxCall(JsVar("u"), jsonStr => {
      logger.debug("Received string: "+jsonStr)
      handler(jsonStr)
      Noop
    })
}

private[ng] case class FromClient(json: String, clientId: Box[String])
private[ng] case class ToClient(cmd: JsCmd)





© 2015 - 2025 Weber Informatics LLC | Privacy Policy