Up: Chapter 6

6.3 Shared Shopping

Let’s move onto a real code example. You can find this code at Shop with Me source.
The example is going to be a simple shopping site. There are a bunch of items that you can view. You have a shopping cart. You can add items to the cart. If you’re viewing the cart in multiple tabs or browser windows, the cart in all tabs/windows will update when you change the cart. Further, you can share your cart with someone else and any changes to the cart will be propagated to all the different browsers sharing the same cart.
The data model is the same that we used in the REST chapter (see on page 1↓).
Let’s look at the shopping cart definition:
Cart.scala
package code
package lib
​
import model.Item
​
import net.liftweb._
import util._
​
/**
 * The shopping cart
 */
class Cart {
  /**
   * The contents of the cart
   */
  val contents = ValueCell[Vector[CartItem]](Vector())
​
  /**
   * The subtotal
   */
  val subtotal = contents.lift(_.foldLeft(zero)(_ + 
                                                _.qMult(_.price)))
​
  /**
   * The taxable subtotal
   */
  val taxableSubtotal = contents.lift(_.filter(_.taxable).
                                      foldLeft(zero)(_ + 
                                                     _.qMult(_.price)))
​
  /**
   * The current tax rate
   */
  val taxRate = ValueCell(BigDecimal("0.07"))
​
  /**
   * The computed tax
   */
  val tax = taxableSubtotal.lift(taxRate)(_ * _)
​
  /**
   * The total
   */
  val total = subtotal.lift(tax)(_ + _)
​
  /**
   * The weight of the cart
   */
  val weight = contents.lift(_.foldLeft(zero)(_ +
                                              _.qMult(_.weightInGrams)))
​
  // Helper methods
​
  /**
   * A nice constant zero
   */
  def zero = BigDecimal(0)
​
  /**
   * Add an item to the cart.  If it's already in the cart,
   * then increment the quantity
   */
  def addItem(item: Item) {
    contents.atomicUpdate(v => v.find(_.item == item) match {
      case Some(ci) => v.map(ci => ci.copy(qnty = ci.qnty + 
                                           (if (ci.item == item) 1 else 0)))
      case _ => v :+ CartItem(item, 1)
    })
  }
​
  /**
   * Set the item quantity.  If zero or negative, remove
   */
  def setItemCnt(item: Item, qnty: Int) {
    if (qnty <= 0) removeItem(item)
    else contents.atomicUpdate(v => v.find(_.item == item) match {
      case Some(ci) => v.map(ci => ci.copy(qnty =
                                           (if (ci.item == item) qnty 
                                            else ci.qnty)))
      case _ => v :+ CartItem(item, qnty)
    })
​
  }
​
  /**
   * Removes an item from the cart
   */
  def removeItem(item: Item) {
    contents.atomicUpdate(_.filterNot(_.item == item))
  }
}
​
/**
 * An item in the cart
 */
case class CartItem(item: Item, qnty: Int, 
                    id: String = Helpers.nextFuncName) {
​
  /**
   * Multiply the quantity times some calculation on the
   * contained Item (e.g., getting its weight)
   */
  def qMult(f: Item => BigDecimal): BigDecimal = f(item) * qnty
}
​
/**
 * The CartItem companion object
 */
object CartItem {
  implicit def cartItemToItem(in: CartItem): Item = in.item
}
Looks pretty straight forward. You’ve got 2 ValueCells, the cart contents and the tax rate. You’ve gota bunch of calculated Cells. At the bottom of the Cart class definition are some helper methods that allow you to add, remove and update cart contents. We also define the CartItem case class that contains the Item and the qnty (quantity).
So far, so good. Next, let’s look at the way we display all the items:
AllItemsPage.scala
package code
package snippet
​
import model.Item
import comet._
​
import net.liftweb._
import http._
import sitemap._
import util._
import Helpers._
​
object AllItemsPage {
  // define the menu item for the page that
  // will display all items
  lazy val menu = Menu.i("Items") / "item" >>
  Loc.Snippet("Items", render)
  
  // display the items
  def render =
    "tbody *" #> renderItems(Item.inventoryItems)
​
  // for a list of items, display those items
  def renderItems(in: Seq[Item]) =
    "tr" #> in.map(item => {
      "a *" #> item.name &
      "a [href]" #> AnItemPage.menu.calcHref(item) &
      "@description *" #> item.description &
      "@price *" #> item.price.toString &
      "@add_to_cart [onclick]" #>
      SHtml.ajaxInvoke(() => TheCart.addItem(item))})
}
​
We define our SiteMap entry:
  lazy val menu = Menu.i("Items") / "item" >>
    Loc.Snippet("Items", render)
So, when the user browses to /item, they’re presented with all the items in inventory.
The template for displaying Items looks like:
items.html
<table class="lift:Items">
  <tbody>
    <tr>
      <td name="name"><a href="#">Name</a></td>
      <td name="description">Desc</td>
      <td name="price">$50.00</td>
      <td><button name="add_to_cart">Add to Cart</button></td>
    </tr>
  </tbody>
</table>
Next, let’s look at the code for displaying an Item:
AnItemPage.scala
package code
package snippet
​
import model.Item
import comet._
​
import net.liftweb._
import util._
import Helpers._
import http._
import sitemap._
​
import scala.xml.Text
​
object AnItemPage {
  // create a parameterized page
  def menu = Menu.param[Item]("Item", Loc.LinkText(i => Text(i.name)),
                              Item.find _, _.id) / "item" / *
}
​
class AnItemPage(item: Item) {
  def render = "@name *" #> item.name &
  "@description *" #> item.description &
  "@price *" #> item.price.toString &
  "@add_to_cart [onclick]" #> SHtml.ajaxInvoke(() => TheCart.addItem(item))
}
​
This defines what happens when the user goes to /item/1234. This is more “controller-like” than most of the other Lift code. Let’s look at the menu item definition:
  def menu = Menu.param[Item]("Item", Loc.LinkText(i => Text(i.name)),
                              Item.find _, _.id) / "item" / *
We are defining a parameterized Menu entry. The parameter type is Item. That means that the page will display an Item and that we must be able to calculate the Item based on the request.
"Item" is the name of the menu entry.
Loc.LinkText(i => Text(i.name)) takes an item and generates the display text for the menu entry.
Item.find _ is a function that takes a String and converts it to Box[Item]. It looks up the Item based on the parameter in the request that we’re interested in.
_.id is a function (Item => String) that takes an Item and returns a String that represents how to build a URL that represents the Item page. This is used by "a [href]" #> AnItemPage.menu.calcHref(item) to convert an Item to the HREF for the page that display the Item.
Finally, the URL is defined by / "item" / * which is pretty much what it looks like. It’ll match an incoming request of the form /item/xxx and xxx is passed to the String => Box[Item] function to determine the Item associated with the URL.
So, we can display all the items. Navigate from all the items to a single item. Each item has a button that allows you to add the Item to the shopping cart. The Item is added to the cart with this code: SHtml.ajaxInvoke(() => TheCart.addItem(item))}). The TheCart.addItem(item) can be called from anywhere in the application without regard for what needs to be updated when the cart is changed.
Let’s look at how the cart is displayed and managed:
CometCart.scala
package code
package comet
​
import lib._
​
import net.liftweb._
import common._
import http._
import util._
import js._
import js.jquery._
import JsCmds._
import scala.xml.NodeSeq
import Helpers._
​
/**
 * What's the current cart for this session
 */
object TheCart extends SessionVar(new Cart())
​
/**
 * The CometCart is the CometActor the represents the shopping cart
 */
class CometCart extends CometActor {
  // our current cart
  private var cart = TheCart.get
​
  /**
   * Draw yourself
   */
  def render = {
    "#contents" #> (
      "tbody" #> 
      Helpers.findOrCreateId(id =>  // make sure tbody has an id
        // when the cart contents updates
        WiringUI.history(cart.contents) {
          (old, nw, ns) => {
            // capture the tr part of the template
            val theTR = ("tr ^^" #> "**")(ns)
            
            def ciToId(ci: CartItem): String = ci.id + "_" + ci.qnty
​
            // build a row out of a cart item
            def html(ci: CartItem): NodeSeq = {
              ("tr [id]" #> ciToId(ci) & 
               "@name *" #> ci.name &
               "@qnty *" #> SHtml.
               ajaxText(ci.qnty.toString,
                        s => {
                          TheCart.
                          setItemCnt(ci, 
                                     Helpers.toInt(s))
                        }, "style" -> "width: 20px;") &
               "@del [onclick]" #> SHtml.
             ajaxInvoke(() => TheCart.removeItem(ci)))(theTR)
            }
            
            // calculate the delta between the lists and
            // based on the deltas, emit the current jQuery
            // stuff to update the display
            JqWiringSupport.calculateDeltas(old, nw, id)(ciToId _, html _)
          }
        })) &
    "#subtotal" #> WiringUI.asText(cart.subtotal) & // display the subttotal
    "#tax" #> WiringUI.asText(cart.tax) & // display the tax
    "#total" #> WiringUI.asText(cart.total) // display the total
  }
   
  /**
   * Process messages from external sources
   */
  override def lowPriority = {
    // if someone sends us a new cart
    case SetNewCart(newCart) => {
      // unregister from the old cart
      unregisterFromAllDepenencies()
​
      // remove all the dependencies for the old cart
      // from the postPageJavaScript
      theSession.clearPostPageJavaScriptForThisPage()
​
      // set the new cart
      cart = newCart
​
      // do a full reRender including the fixed render piece
      reRender(true)
    }
  }
}
​
/**
 * Set a new cart for the CometCart
 */
case class SetNewCart(cart: Cart)
Let’s walk through the code:
object TheCart extends SessionVar(new Cart())
We define a SessionVar that holds the shopping cart.
Our CometActor captures the the current cart from the SessionVar:
class CometCart extends CometActor {
  // our current cart
  private var cart = TheCart.get
Next, let’s see how to draw the cart.total:
"#total" #> WiringUI.asText(cart.total) // display the total
That’s pretty much the way it should be.
Let’s look at the gnarly piece... how to draw or redraw the cart contents based on changes and only send the JavaScript the will manipulate the browser DOM to add or remove items from the cart:
"#contents" #> (
      "tbody" #> 
      Helpers.findOrCreateId(id =>  // make sure tbody has an id
        // when the cart contents updates
        WiringUI.history(cart.contents) {
          (old, nw, ns) => {
            // capture the tr part of the template
            val theTR = ("tr ^^" #> "**")(ns)
            
            def ciToId(ci: CartItem): String = ci.id + "_" + ci.qnty
​
            // build a row out of a cart item
            def html(ci: CartItem): NodeSeq = {
              ("tr [id]" #> ciToId(ci) & 
               "@name *" #> ci.name &
               "@qnty *" #> SHtml.
               ajaxText(ci.qnty.toString,
                        s => {
                          TheCart.
                          setItemCnt(ci, 
                                     Helpers.toInt(s))
                        }, "style" -> "width: 20px;") &
               "@del [onclick]" #> SHtml.
             ajaxInvoke(() => TheCart.removeItem(ci)))(theTR)
            }
            
            // calculate the delta between the lists and
            // based on the deltas, emit the current jQuery
            // stuff to update the display
            JqWiringSupport.calculateDeltas(old, nw, id)(ciToId _, html _)
          }
        }))
First, we make sure we know the id of the <tbody> element: "tbody" #> Helpers.findOrCreateId(id =>
Next, wire the CometCart up to the cart.contents such that when the contents change, we get the old value (old), the new value (nw) and the memoized NodeSeq (the template used to do the rendering): WiringUI.history(cart.contents) { (old, nw, ns) => {
Capture the part of the template associated with the <tr> element in the theTR variable: val theTR = ("tr ^^" #> "**")(ns)
Based on a CartItem, return a stable id for the DOM node the represents the CartItem:
The html method converts a CartItem to a NodeSeq including Ajax controls for changing quantity and removing the item from the cart.
Finally, based on the deltas between the old list of CartItem and the new list, generate the JavaScript that will manipulate the DOM by inserting and removing the appropriate DOM elements: JqWiringSupport.calculateDeltas(old, nw, id)(ciToId _, html _)
Next, let’s see how to change the cart. If we want to share the shopping cart between two browser sessions... two people shopping at their browser, but putting things in a single cart, we need a way to change the cart. We process the SetNewCart message to CometCart:
    // if someone sends us a new cart
    case SetNewCart(newCart) => {
      // unregister from the old cart
      unregisterFromAllDepenencies()
​
      // remove all the dependencies for the old cart
      // from the postPageJavaScript
      theSession.clearPostPageJavaScriptForThisPage()
​
      // set the new cart
      cart = newCart
​
      // do a full reRender including the fixed render piece
      reRender(true)
    }
There are two lines in the above code that hint at how Wiring interacts with Lift’s Comet support: unregisterFromAllDepenencies() and theSession.clearPostPageJavaScriptForThisPage()
When a CometActor depends on something in WiringUI, Lift generates a weak reference between the Cell and the CometActor. When the Cell changes value, it pokes the CometActor. The CometActor then updates the browser’s screen real estate associated with changes to Cells. unregisterFromAllDepenencies() disconnects the CometActor from the Cells. theSession.clearPostPageJavaScriptForThisPage() removes all the postPageJavaScript associated with the CometActor. Because the CometActor is not associated with a single page, but can appear on many pages, it has its own postPageJavaScript context.
The final piece of the puzzle is how we share a Cart across sessions. From the UI perspective, here’s how we display the modal dialog when the user presses the “Share Cart” button:
Link.scala
package code
package snippet
​
import model._
import comet._
import lib._
​
import net.liftweb._
import http._
import util.Helpers._
import js._
import JsCmds._
import js.jquery.JqJsCmds._
​
class Link {
  // open a modal dialog based on the _share_link.html template
  def request = "* [onclick]" #> SHtml.ajaxInvoke(() => {
    (for {
      template <- TemplateFinder.findAnyTemplate(List("_share_link"))
    } yield ModalDialog(template)) openOr Noop
      
  })
​
  // close the modal dialog
  def close = "* [onclick]" #> SHtml.ajaxInvoke(() => Unblock)
​
  // Generate the href and link for sharing
  def generate = {
    val s = ShareCart.generateLink(TheCart)
    "a [href]" #> s & "a *" #> s
  }
}
Basically, we use jQuery’s ModalDialog plugin to put a dialog up that contains a link generated by the ShareCart object. Let’s look at ShareCart.scala:
ShareCart.scala
package code
package lib
​
import comet._
​
import net.liftweb._
import common._
import http._
import rest.RestHelper
import util._
import Helpers._
​
// it's a RestHelper
object ShareCart extends RestHelper {
  // private state
  private var carts: Map[String, (Long, Cart)] = Map()
​
  // given a Cart, generate a unique sharing code
  def codeForCart(cart: Cart): String = synchronized {
    val ret = Helpers.randomString(12)
​
    carts += ret -> (10.minutes.later.millis -> cart)
​
    ret
  }
​
  /**
   * Generate the right link to this cart
   */
  def generateLink(cart: Cart): String = {
    S.hostAndPath + "/co_shop/"+codeForCart(cart)
  }
​
  // An extractor that converts a String to a Cart, if
  // possible
  def unapply(code: String): Option[Cart] = synchronized {
    carts.get(code).map(_._2)
  }
​
  // remove any carts that are 10+ minutes old
  private def cleanup() {
    val now = Helpers.millis
    synchronized{
      carts = carts.filter{
        case (_, (time, _)) => time > now
      }
    }
    Schedule.schedule(() => cleanup(), 5 seconds)
  }
  
  // clean up every 5 seconds
  cleanup()
​
  // the REST part of the code
  serve {
    // match the incoming URL
    case "co_shop" :: ShareCart(cart) :: Nil Get _ => {
      // set the cart
      TheCart.set(cart)
      
      // send the SetNewCart message to the CometCart
      S.session.foreach(
        _.sendCometActorMessage("CometCart", Empty,
                                SetNewCart(cart)))
​
      // redirect the browser to /
      RedirectResponse("/")
    }
  }
}
The code manages the association between random IDs and Carts. If the user browses to /co_shop/share_cart_id, ShareCart will set TheCart to the shared Cart and send a SetNewCart message to the CometCart instance associated with the session.
Up: Chapter 6

(C) 2012 David Pollak