Implements .fromKVPairs() with nested object support for most all of the models.
authordsc <dsc@less.ly>
Tue, 28 Feb 2012 04:32:14 +0000 (20:32 -0800)
committerdsc <dsc@less.ly>
Tue, 28 Feb 2012 04:32:14 +0000 (20:32 -0800)
lib/base.co
lib/graph/model.co
lib/graph/view.co
lib/scaffold/model.co
lib/scaffold/view.co
lib/underscore/object.co

index a7d23c9..f3766f4 100644 (file)
@@ -2,46 +2,76 @@ _  = require 'kraken/underscore'
 op = require 'kraken/util/op'
 
 
+/**
+ * @class Base model, extending Backbone.Model, used by scaffold and others.
+ * @extends Backbone.Model
+ */
 BaseModel = exports.BaseModel = Backbone.Model.extend do # {{{
-    ctorName : 'BaseModel'
-    # List of methods to bind on initialize; set on subclass
+    # A list of method-names to bind on initialize; set this on a subclass to override.
     __bind__ : []
-    
-    idAttribute    : 'id'
-    valueAttribute : 'value'
-    
+    ctorName : 'BaseModel'
     
     initialize: ->
         _.bindAll this, ...@__bind__ if @__bind__.length
         @__super__ = @constructor.__super__
     
-    
-    toKVPairs: (kv_delimiter='=') ->
-        idAttr  = @idAttribute or 'id'
-        key     = @[idAttr] or @get idAttr
-        valAttr = @valueAttribute or 'value'
-        value   = @get 'value'
-        if key and value?
-            "#{encodeURIComponent key}#kv_delimiter#{encodeURIComponent value}"
-        else
-            ''
-    
-    toString: -> "#{@ctorName}(id=#{@id}, value=#{@get 'value'})"
+    /**
+     * Like `.toJSON()` in that it should return a plain object with no functions,
+     * but for the purpose of `.toKVPairs()`, allowing you to customize the values
+     * included and keys used.
+     * @returns {Object}
+     */
+    toKVObject: ->
+        _.collapseObject @toJSON()
+    
+    /**
+     * Serialize the model into a `www-form-encoded` string suitable for use as
+     * a query string or a POST body.
+     * @returns {String}
+     */
+    toKVPairs: (item_delim='&', kv_delim='=') ->
+        _.toKVPairs @toKVObject(), item_delim, kv_delim
+    
+    toString: -> "#{@ctorName}(id=#{@id})"
 
 
 # Class Methods
 BaseModel import do
     
-    fromKVPairs: (o) ->
-        o   = _.fromKVPairs o if typeof o is 'string'
+    /**
+     * Factory method which constructs an instance of this model from a string of KV-pairs.
+     * This is a class method inherited by models which extend {BaseModel}.
+     * @static
+     * @param {String|Object} o Serialized KV-pairs (or a plain object).
+     * @returns {BaseModel} An instance of this model.
+     */
+    fromKVPairs: (o, item_delim='&', kv_delim='=') ->
+        o   = _.fromKVPairs o, item_delim, kv_delim if typeof o is 'string'
         Cls = if typeof this is 'function' then this else this.constructor
-        new Cls o
+        new Cls _.uncollapseObject o
+
+# }}}
 
+/**
+ * @class Base collection, extending Backbone.Collection, used by scaffold and others.
+ * @extends Backbone.Collection
+ */
+BaseList = exports.BaseList = Backbone.Collection.extend do # {{{
+    ctorName : 'BaseList'
+    
+    toKVObject: ->
+        _.collapseObject @toJSON()
+    
+    toKVPairs: (item_delim='&', kv_delim='=') ->
+        _.toKVPairs @toKVObject(), item_delim, kv_delim
+    
+    toString: -> "#{@ctorName}(length=#{@length})"
 # }}}
 
 
 /**
- * @class Base View, used by scaffold and others.
+ * @class Base view, extending Backbone.View, used by scaffold and others.
+ * @extends Backbone.View
  */
 BaseView = exports.BaseView = Backbone.View.extend do # {{{
     ctorName : 'BaseView'
index 7932914..9a0cf32 100644 (file)
@@ -89,33 +89,12 @@ GraphOption = exports.GraphOption = Field.extend do # {{{
         for k, v in o
             o[k] = '' if v!?
         o
-    
-    toKVPairs: ->
-        key = @get 'name'
-        value = @get 'value'
-        if value?
-            "#{encodeURIComponent key}=#{encodeURIComponent value}"
-        else
-            ''
-    
-
 # }}}
 
 
 GraphOptionList = exports.GraphOptionList = FieldList.extend do # {{{
     ctorName   : 'GraphOptionList'
     model      : GraphOption
-    
-    /**
-     * Transforms this list into form-encoded KV-pairs, excluding null values and 
-     * @returns {String}
-     */
-    toKVPairs: (keepDefaults=false) ->
-        @models
-            .filter -> it.get('name') and it.getValue()? and (keepDefaults or not it.isDefault())
-            .map -> "#{encodeURIComponent it.get 'name'}=#{encodeURIComponent it.serializeValue()}"
-            .join '&'
-    
 # }}}
 
 
@@ -132,21 +111,24 @@ GraphModel = exports.GraphModel = BaseModel.extend do # {{{
         if name and not (@id or @has 'id')
             @id = @attributes.id = _.underscored name
     
-    defaults : ->
+    defaults: ->
         {
+            id      : 'graph'
             name    : 'Kraken Graph'
             dataset : '/data/pageviews_by.timestamp.language.csv'
+            desc    : ''
+            width   : 'auto'
+            height  : 'auto'
             options : {}
         }
     
     
     hasOption: (key) ->
         options = @get 'options', {}
-        key in options
+        options[key]?
     
     getOption: (key, def) ->
-        options = @get 'options', {}
-        if key in options then options[key] else def
+        @get('options', {})[key] ? def
     
     setOption: (key, value, opts={}) ->
         options = @get 'options', {}
@@ -161,10 +143,8 @@ GraphModel = exports.GraphModel = BaseModel.extend do # {{{
         @trigger "change:options:#key", this, value, key, opts unless opts.silent
     
     
-    toKVPairs: ->
-        ...
-    
     toString: -> "#{@ctorName}(id=#{@id}, name=#{@get 'name'}, dataset=#{@get 'dataset'})"
+
 # }}}
 
 
index f8c335c..a734073 100644 (file)
@@ -29,13 +29,6 @@ GraphOptionView = exports.GraphOptionView = FieldView.extend do # {{{
         'click .collapsed'                   : 'onClick'
     
     
-    update: ->
-        val     = @$el.find('.value').val()
-        current = @model.get 'value'
-        return if val is current
-        console.log "#this.update( #current -> #val )"
-        @model.setValue val, {+silent}
-    
     render: ->
         @__super__.render ...
         @$el.addClass 'collapsed' if @isCollapsed
@@ -80,21 +73,17 @@ GraphOptionsScaffold = exports.GraphOptionsScaffold = Scaffold.extend do # {{{
             # itemPositionDataEnabled : true
             # animationEngine : 'jquery'
     
+    /**
+     * Add a GraphOption to this scaffold, rerendering the isotope
+     * layout after collapse events.
+     */
     addOne: (field) ->
-        # console.log "#this.addOne!", field
-        
-        field.on 'change:value', ~>
-            key   = field.get 'name'
-            value = field.getValue()
-            @trigger "change:#key", this, value, key, field
-            @trigger "change",      this, value, key, field
-        
         view = @__super__.addOne ...
         view.on 'change:collapse render', @render
         view
     
     toKVPairs: ->
-        @collection.toKVPairs()
+        @collection.toKVPairs ...
     
 # }}}
 
@@ -114,19 +103,18 @@ GraphView = exports.GraphView = BaseView.extend do # {{{
     
     
     initialize : (o={}) ->
-        @render    = _.debounce @render,    DEBOUNCE_RENDER
-        @renderAll = _.debounce @renderAll, DEBOUNCE_RENDER
-        
         @model or= new GraphModel
         BaseView::initialize ...
         # console.log "#this.initialize!"
+        @render    = _.debounce @render,    DEBOUNCE_RENDER
+        @renderAll = _.debounce @renderAll, DEBOUNCE_RENDER
         
+        @model.on 'destroy', @remove, this
+        @model.on 'change',  @render, this
         @model.on 'change:options', ~>
             changes = @model.changedAttributes()
             console.log 'Model.changed(options) ->', changes
             @chartOptions changes, {+silent}
-        @model.on 'change',  @render, this
-        @model.on 'destroy', @remove, this
         
         @build()
         @viewport = @$el.find '.viewport'
@@ -136,13 +124,13 @@ GraphView = exports.GraphView = BaseView.extend do # {{{
         @scaffold.collection.reset that if o.graph_spec
         
         @scaffold.on 'change', (scaffold, value, key, field) ~>
-            console.log "scaffold.change", value, key, field
+            console.log "scaffold.change!", value, key, field
             @model.setOption key, value, {+silent}
         
         options = @model.get 'options', {}
         @chartOptions options, {+silent}
         
-        _.delay @render, DEBOUNCE_RENDER
+        _.delay @renderAll, DEBOUNCE_RENDER
     
     
     chartOptions: (values, opts) ->
@@ -204,9 +192,8 @@ GraphView = exports.GraphView = BaseView.extend do # {{{
         @render()
         false
     
-    
-    toKVPairs: (keepDefaults=false) ->
-        @scaffold.toKVPairs keepDefaults
+    toKVPairs: ->
+        @model.toKVPairs ...
     
     toString: -> "#{@ctorName}(#{@model})"
 # }}}
index 083c0e3..91ff110 100644 (file)
@@ -1,6 +1,6 @@
 _    = require 'kraken/underscore'
 op   = require 'kraken/util/op'
-{BaseModel} = require 'kraken/base'
+{BaseModel, BaseList} = require 'kraken/base'
 
 
 
@@ -68,19 +68,9 @@ Field = exports.Field = BaseModel.extend do # {{{
             null
     
     
-    /* * * Serializers * * */
-    serializeValue: ->
-        v = @getValue()
-        if v!?
-            v = ''
-        else if _.isArray(v) or _.isObject(v)
-            v = JSON.stringify v
-        String v
-    
-    
     /* * * Value Accessors * * */
     getValue: (def) ->
-        @getParser() @get 'value', def
+        @getParser() @get @valueAttribute, def
     
     setValue: (v, options) ->
         def = @get 'default'
@@ -88,45 +78,58 @@ Field = exports.Field = BaseModel.extend do # {{{
             val = null
         else
             val = @getParser()(v)
-        @set 'value', val, options
+        @set @valueAttribute, val, options
     
     clearValue: ->
-        @set 'value', @get('default')
+        @set @valueAttribute, @get('default')
     
     isDefault: ->
-        @get('value') is @get('default')
+        @get(@valueAttribute) is @get('default')
     
     
+    /* * * Serializers * * */
+    serializeValue: ->
+        v = @getValue()
+        if v!?
+            v = ''
+        else if _.isBoolean v
+            v =  Number v
+        else if _.isArray(v) or _.isObject(v)
+            v = JSON.stringify v
+        String v
+    
     toJSON: ->
         {id:@id} import do
             _.clone(@attributes) import { value:@getValue(), def:@get('default') }
     
+    toKVObject: ->
+        { "#{@id}": @serializeValue() }
     
-    toString: -> "(#{@id}: #{@get 'value'})"
+    toString: -> "(#{@id}: #{@serializeValue()})"
 # }}}
 
 
-FieldList = exports.FieldList = Backbone.Collection.extend do # {{{
+FieldList = exports.FieldList = BaseList.extend do # {{{
     ctorName : 'FieldList'
-    model : Field
+    model    : Field
     
     /**
      * Collects a map of fields to their values, excluding those set to `null` or their default.
      * @returns {Object}
      */
-    values: ->
+    values: (keepDefaults=false) ->
         _.synthesize do
-            @models.filter -> not it.isDefault()
+            if keepDefaults then @models else @models.filter -> not it.isDefault()
             -> [ it.get('name'), it.getValue() ]
     
     toJSON: ->
         @values()
     
-    toKVPairs: ->
-        @models
-            .filter -> it.get('name') and it.getValue()?
-            .map -> "#{encodeURIComponent it.id}=#{encodeURIComponent it.get 'value'}"
-            .join '&'
+    toKVObject: ->
+        _.collapseObject @toJSON()
+    
+    toKVPairs: (item_delim='&', kv_delim='=') ->
+        _.toKVPairs @toKVObject(), item_delim, kv_delim
     
     toString: -> "#{@ctorName}(length=#{@length})"
 # }}}
index e9c9c89..b16cceb 100644 (file)
@@ -10,20 +10,21 @@ FieldView = exports.FieldView = BaseView.extend do # {{{
     className : 'field'
     
     events :
-        'blur .value'   : 'onUIChange'
-        'submit .value' : 'onUIChange'
+        'blur .value'   : 'update'
+        'submit .value' : 'update'
     
     
     # initialize: ->
     #     # console.log "#this.initialize!"
     #     BaseView::initialize ...
     
-    onUIChange: ->
-        val     = @$el.find('.value').val()
-        current = @model.get 'value'
-        return if val is current
-        console.log "#this.onUIChange( #current -> #val )"
+    update: ->
+        val     = @model.getParser() @$el.find('.value').val()
+        current = @model.getValue()
+        return if _.isEqual val, current
+        console.log "#this.update( #current -> #val )"
         @model.setValue val, {+silent}
+        @trigger 'update', this
     
     render: ->
         return @remove() if @model.get 'hidden', false
@@ -74,10 +75,17 @@ Scaffold = exports.Scaffold = BaseView.extend do # {{{
         # console.log "[S] #this.addOne!", @__super__
         _.remove @subviews, field.view if field.view
         
+        # avoid duplicating event propagation
+        field.off 'change:value', @change, this
+        
+        # propagate value-change events as key-value change events
+        field.on 'change:value', @change, this
+        
         SubviewType = @subviewType
         view = new SubviewType model:field
         @subviews.push view
         @$el.append view.render().el unless field.get 'hidden'
+        view.on 'update', @change.bind(this, field)
         
         @render()
         view
@@ -88,18 +96,22 @@ Scaffold = exports.Scaffold = BaseView.extend do # {{{
         @collection.each @addOne
         this
     
-    get: (id) ->
-        @collection.get id
-    
-    at: (idx) ->
-        @collection.at idx
-    
-    pluck: (prop) ->
-        @collection.pluck prop
+    change: (field) ->
+        key   = field.get 'name'
+        value = field.getValue()
+        @trigger "change:#key", this, value, key, field
+        @trigger "change",      this, value, key, field
+        this
     
-    values: ->
-        @collection.values()
     
     toString: -> "#{@ctorName}(collection=#{@collection})"
+
+
+
+# Proxy collection methods
+<[ get at pluck invoke values toJSON toKVObject toKVPairs ]>
+    .forEach (methodname) ->
+        Scaffold::[methodname] = -> @collection[methodname].apply @collection, arguments
+
 # }}}
 
index de10a0c..22f7fe1 100644 (file)
@@ -1,6 +1,26 @@
 _ = require 'underscore'
 
+OBJ_PROTO = Object.prototype
+getProto = Object.getPrototypeOf
+
 _obj = do
+    
+    isPlainObject : (o) ->
+        !! o and _.isObject(o) and OBJ_PROTO is getProto(o)
+    
+    /**
+     * Converts the collection to a list of its items:
+     * - Objects become a list of `[key, value]` pairs.
+     * - Strings become a list of characters.
+     * - Arguments objects become an array.
+     * - Arrays are copied.
+     */
+    items: (obj) ->
+        if _.isObject(obj) and not _.isArguments(obj)
+            _.map obj, (v, k) -> [k, v]
+        else
+            [].slice.call obj
+    
     /**
      * In-place removal of a value from an Array or Object.
      */
@@ -17,35 +37,61 @@ _obj = do
     
     
     
-    toKVPairs: (o, item_delimiter='&', kv_delimiter='=') ->
+    toKVPairs: (o, item_delim='&', kv_delim='=') ->
         _.reduce do
             o
             (acc, v, k) ->
-                acc.push encodeURIComponent(k)+kv_delimiter+encodeURIComponent(v) if k
+                acc.push encodeURIComponent(k)+kv_delim+encodeURIComponent(v) if k
                 acc
             []
-        .join item_delimiter
-    
+        .join item_delim
     
-    fromKVPairs: (qs, item_delimiter='&', kv_delimiter='=') ->
+    fromKVPairs: (qs, item_delim='&', kv_delim='=') ->
         _.reduce do
-            qs.split item_delimiter
+            qs.split item_delim
             (acc, pair) ->
-                [k, v] = pair.split kv_delimiter
+                idx = pair.indexOf kv_delim
+                if idx is not -1
+                    [k, v] = [pair.slice(0, idx), pair.slice(idx+1)]
+                else
+                    [k, v] = [pair, '']
                 acc[ decodeURIComponent k ] = decodeURIComponent v if k
                 acc
             {}
-        
+    
+    /**
+     * @returns {Object} Copies and flattens any sub-objects into namespaced keys on the parent object,
+     * such that `{ "foo":{ "bar":1 } }` becomes `{ "foo.bar":1 }`.
+     */
+    collapseObject: (obj, parent={}, prefix='') ->
+        prefix += '.' if prefix
+        _.each obj, (v, k) ->
+            if _.isPlainObject v
+                _.collapseObject v, parent, prefix+k
+            else
+                parent[prefix+k] = v
+        parent
+    
+    uncollapseObject: (obj) ->
+        _.reduce do
+            obj
+            (acc, v, k) ->
+                _.setNested acc, k, v, true
+                acc
+            {}
     
     /**
      * Searches a heirarchical object for a given subkey specified in dotted-property syntax.
      * @param {Object} base The object to serve as the root of the property-chain.
      * @param {Array|String} chain The property-chain to lookup.
+     * @param {Boolean} [ensure=false] If true, intermediate keys that are `null` or
+     *  `undefined` will be filled in with a new empty object `{}`, ensuring the get will
+     *   return valid metadata.
      * @retruns {null|Object} If found, the object is of the form `{ key: Qualified key name, obj: Parent object of key, val: Value at obj[key] }`. Otherwise `null`.
      */
-    getNestedMeta : (obj, chain) ->
+    getNestedMeta : (obj, chain, ensure=false) ->
         chain = chain.split('.') if typeof chain is 'string'
-        return _.reduce do
+        _.reduce do
             chain
             (current, key, idx, chain) ->
                 return null unless current?
@@ -56,8 +102,11 @@ _obj = do
                         key : key
                         val : current[key]
                 
-                if key in current
-                    current[key]
+                val = current[key]
+                if val?
+                    val
+                else if ensure 
+                    current[key] = {}
                 else
                     null
             obj
@@ -82,10 +131,13 @@ _obj = do
      * @param {Object} obj The object to serve as the root of the property-chain.
      * @param {Array|String} chain The property-chain to lookup.
      * @param {Any} value The value to set.
-     * @retruns {null|Object} If found, returns the old value, and otherwise `null`.
+     * @param {Boolean} [ensure=false] If true, intermediate keys that are `null` or
+     *  `undefined` will be filled in with a new empty object `{}`, ensuring the set
+     *  will succeed.
+     * @retruns {null|Any} If found, returns the old value, and otherwise `null`.
      */
-    setNested : (obj, chain, value) ->
-        meta = _obj.getNestedMeta obj, chain
+    setNested : (obj, chain, value, ensure=false) ->
+        meta = _obj.getNestedMeta obj, chain, ensure
         if meta
             meta.obj[meta.key] = value
             meta.val