Updates ParserMixin, working on simplifying Scaffolding.
authordsc <dsc@wikimedia.org>
Fri, 11 May 2012 18:00:01 +0000 (11:00 -0700)
committerdsc <dsc@wikimedia.org>
Fri, 11 May 2012 18:00:01 +0000 (11:00 -0700)
14 files changed:
lib/base/base-mixin.co
lib/base/base-model.co
lib/chart/chart-option-model.co
lib/chart/chart-option-view.co
lib/chart/chart-type.co
lib/scaffold/scaffold-model.co
lib/scaffold/scaffold-view.co
lib/template/chart-option.jade
lib/template/chart-scaffold.jade
lib/timeseries/csv.co
lib/timeseries/timeseries.co
lib/util/backbone.co
lib/util/op.co
lib/util/parser.co

index 0b081a7..29a8581 100644 (file)
@@ -7,7 +7,6 @@ Backbone = require 'backbone'
 
 BaseBackboneMixin = exports.BaseBackboneMixin =
     
-    
     initialize: ->
         @__apply_bind__()
     
@@ -142,7 +141,76 @@ BaseBackboneMixin = exports.BaseBackboneMixin =
         "#{@getClassName()}()"
 
 
-mixinBase = exports.mixinBase = (body) ->
-    _.clone(BaseBackboneMixin) import body
+
+
+/**
+ * @class Base mixin class. Extend this to create a new mixin, attaching the
+ *  donor methods as you would instance methods. Your constructor must delegate
+ *  to its superclass, passing itself as the context.
+ * 
+ *  Then, to mingle your mixin with another class or object, invoke the mixin
+ *  *without* the `new` operator:
+ *  
+ *  class MyMixin extends Mixin
+ *      -> return Mixin.call MyMixin, it
+ *      foo: -> "foo!"
+ *  
+ *  # Mix into an object...
+ *  o = MyMixin { bar:1 }
+ *  
+ *  # Mix into a Coco class...
+ *  class Bar
+ *      MyMixin this
+ *      bar : 1
+ *  
+ */
+class exports.Mixin
+    
+    /**
+     * Mixes this mixin into If the target is not a class, a new object will be
+     * returned which inherits from the mixin.
+     * @constructor
+     */
+    (target) ->
+        return unless target
+        
+        MixinClass = Mixin
+        MixinClass = @constructor   if this instanceof Mixin
+        MixinClass = this           if this instanceof Function
+        
+        if typeof target is 'function'
+            target:: import MixinClass::
+        else
+            target = _.clone(MixinClass::) import target
+        
+        return target
+    
+
+
+
+# /**
+#  * @returns {Function} Function which takes a target object or class,
+#  *  mixes the MixinClass into it, and then returns it. If the target is
+#  *  not a class, a new object will be returned which inherits from the mixin.
+#  */
+# makeMixer = exports.makeMixer = (MixinClass) ->
+#     mixinBody = if typeof MixinClass is 'function' then MixinClass:: else MixinClass
+#     mixinMixer = (target) ->
+#         if typeof target is 'function'
+#             target:: import mixinBody
+#         else
+#             target = _.clone(mixinBody) import target
+#         target
+# 
+# mixinBase = exports.mixinBase = makeMixer BaseBackboneMixin
+
+
+/**
+ * Mixes BaseBackboneMixin into another object or prototype.
+ * @returns {Object} The merged prototype object.
+ */
+mixinBase = exports.mixinBase = (...bodies) ->
+    _.extend _.clone(BaseBackboneMixin), ...bodies
+
 
 
index 9723f78..5048a1d 100644 (file)
@@ -13,7 +13,6 @@ Backbone = require 'backbone'
  */
 BaseModel = exports.BaseModel = Backbone.Model.extend mixinBase do # {{{
     
-    
     constructor : function BaseModel
         @__class__      = @constructor
         @__superclass__ = @..__super__.constructor
@@ -32,6 +31,8 @@ BaseModel = exports.BaseModel = Backbone.Model.extend mixinBase do # {{{
     get: (key) ->
         _.getNested @attributes, key
     
+    # TODO: nested sets, handling events
+    
     # set: (key, value, opts) ->
     #     if _.isObject(key) and key?
     #         [values, opts] = [key, value]
@@ -189,19 +190,20 @@ BaseModel import do
 # }}}
 
 
+
 /**
  * @class Base collection, extending Backbone.Collection, used by scaffold and others.
  * @extends Backbone.Collection
  */
 BaseList = exports.BaseList = Backbone.Collection.extend mixinBase do # {{{
     
-    
     constructor : function BaseList
         @__class__      = @constructor
         @__superclass__ = @..__super__.constructor
         @waitingOn      = 0
         Backbone.Collection ...
-        @trigger 'create', this
+        # @trigger 'create', this
+    
     
     getIds: ->
         @models.map -> it.id or it.get('id') or it.cid
index cdba717..2a3c37a 100644 (file)
@@ -1,6 +1,9 @@
-_ = require 'kraken/util/underscore'
-{ Field, FieldList, FieldView, Scaffold,
-} = require 'kraken/scaffold'
+{ _, op,
+} = require 'kraken/util'
+{ Parsers, ParserMixin, ParsingModel, ParsingView,
+} = require 'kraken/util/parser'
+{ BaseModel, BaseList,
+}  = require 'kraken/base'
 
 
 /**
@@ -43,16 +46,34 @@ KNOWN_TAGS = exports.KNOWN_TAGS = new TagSet()
 /**
  * @class Field with chart-option-specific handling for validation, parsing, tags, etc.
  */
-ChartOption = exports.ChartOption = Field.extend do # {{{
+ChartOption = exports.ChartOption = ParsingModel.extend do # {{{
     IGNORED_TAGS : <[ callback deprecated debugging ]>
+    valueAttribute : 'value'
+    
+    defaults: ->
+        name     : ''
+        type     : 'String'
+        default  : null
+        desc     : ''
+        include  : 'diff'
+        tags     : []
+        examples : []
+    
     
     
     constructor: function ChartOption
-        Field ...
+        ParsingModel ...
     
     initialize : ->
         # console.log "#this.initialize!"
-        Field::initialize ...
+        
+        # Bind all the `parseXXX()` methods so they can be passed about independent from the class
+        _.bindAll this, ...(_.functions this .filter -> _.startsWith(it, 'parse'))
+        
+        ChartOption.__super__.initialize ...
+        @set 'id',    @id = _.camelize @get 'name'
+        @set 'value', @get('default'), {+silent} if not @has 'value'
+        
         
         # Notify Tag indexer of category when created, to ensure all category-tags
         # get indices with colors :P
@@ -65,6 +86,9 @@ ChartOption = exports.ChartOption = Field.extend do # {{{
             @set 'ignore', true
     
     
+    
+    ### Tag Handling
+    
     # Wrapper to ensure @set('tags') is called, as tags.push()
     # will not trigger the 'changed:tags' event.
     addTag: (tag) ->
@@ -99,11 +123,50 @@ ChartOption = exports.ChartOption = Field.extend do # {{{
         @getTagIndex @getCategory()
     
     
+    
+    /* * * Value Accessors * * */
+    
+    getValue: (def) ->
+        @getParser() @get @valueAttribute, def
+    
+    setValue: (v, options) ->
+        def = @get 'default'
+        if not v and def == null
+            val = null
+        else
+            val = @getParser()(v)
+        @set @valueAttribute, val, options
+    
+    clearValue: ->
+        @set @valueAttribute, @get 'default'
+    
+    isDefault: ->
+        @get(@valueAttribute) is @get 'default'
+    
+    
+    
+    /* * * Serialization * * */
+    
+    /**
+     * Override to default `type` to the model attribute of the same name.
+     * @returns {Function} Parser for the given type.
+     */
+    getParser: (type) ->
+        type or= @get('type') or 'String'
+        ChartOption.__super__.getParser.call this, type
+    
+    serializeValue: ->
+        @serialize @getValue()
+    
     toJSON: ->
-        o = Field::toJSON ...
-        for k, v in o
-            o[k] = '' if v!?
-        o
+        {id:@id} import do
+            _.clone(@attributes) import { value:@getValue(), def:@get 'default' }
+    
+    toKVPairs: ->
+        { "#{@id}":@serializeValue() }
+    
+    toString: -> "(#{@id}: #{@serializeValue()})"
+
 # }}}
 
 
@@ -111,18 +174,45 @@ ChartOption = exports.ChartOption = Field.extend do # {{{
 /**
  * @class List of ChartOption fields.
  */
-ChartOptionList = exports.ChartOptionList = FieldList.extend do # {{{
+ChartOptionList = exports.ChartOptionList = BaseList.extend do # {{{
     model : ChartOption
     
     
     constructor: function ChartOptionList
-        FieldList ...
+        BaseList ...
+    
+    
+    /**
+     * Collects a map of fields to their values, excluding those set to `null` or their default.
+     * 
+     * @param {Object} [opts={}] Options:
+     * @param {Boolean} [opts.keepDefaults=true] If false, exclude pairs that
+     *  haven't changed from their default value.
+     * @param {Boolean} [opts.serialize=false] If true, replace each value
+     *  with its String version by calling `value.serializeValue()`.
+     * @returns {Object} Map of fields to their values.
+     */
+    values: (opts={}) ->
+        opts = {+keepDefaults, -serialize} import opts
+        _.synthesize do
+            if opts.keepDefaults then @models else @models.filter -> not it.isDefault()
+            -> [ it.get('name'), if opts.serialize then it.serializeValue() else it.getValue() ]
+    
+    toJSON: ->
+        @values {+keepDefaults, -serialize}
     
     /**
      * Override to omit defaults from URL.
      */
     toKVPairs: ->
-         _.collapseObject @values {-keepDefaults, +serialize}
+        _.collapseObject @values {-keepDefaults, +serialize}
+    
+    toKV: (item_delim='&', kv_delim='=') ->
+        _.toKV @toKVPairs(), item_delim, kv_delim
     
+    toURL: (item_delim='&', kv_delim='=') ->
+        "?#{@toKV ...}"
+    
+
 # }}}
 
index 86d86e0..5a6f84f 100644 (file)
@@ -1,6 +1,7 @@
-_ = require 'kraken/util/underscore'
-{ Field, FieldList, FieldView, Scaffold,
-} = require 'kraken/scaffold'
+{ _, op,
+} = require 'kraken/util'
+{ BaseView,
+}  = require 'kraken/base'
 { ChartOption, ChartOptionList,
 } = require 'kraken/chart/chart-option-model'
 
@@ -10,33 +11,55 @@ DEBOUNCE_RENDER = exports.DEBOUNCE_RENDER = 100ms
 /**
  * @class View for a single configurable option in a chart type.
  */
-ChartOptionView = exports.ChartOptionView = FieldView.extend do # {{{
-    # __bind__  : <[ onClick ]>
+ChartOptionView = exports.ChartOptionView = BaseView.extend do # {{{
     tagName   : 'section'
     className : 'chart-option field'
     template  : require 'kraken/template/chart-option'
     
+    type : 'string'
     isCollapsed : true
     
     events :
-        'blur .value'                        : 'change'
-        'click input[type="checkbox"].value' : 'change'
-        'submit .value'                      : 'change'
+        'blur .value'                        : 'onChange'
+        'click input[type="checkbox"].value' : 'onChange'
+        'submit .value'                      : 'onChange'
         'click .close'                       : 'toggleCollapsed'
         'click h3'                           : 'toggleCollapsed'
         'click .collapsed'                   : 'onClick'
     
     
+    
     constructor: function ChartOptionView
-        FieldView ...
+        BaseView ...
+    
+    initialize: ->
+        ChartOptionView.__super__.initialize ...
+        @type = @model.get 'type' .toLowerCase() or 'string'
+    
+    
+    /* * * *  Rendering  * * * */
     
+    toTemplateLocals: ->
+        json = ChartOptionView.__super__.toTemplateLocals ...
+        json.id or= _.camelize json.name
+        json.value ?= ''
+        json.value = JSON.stringify v if v = json.value and (_.isArray(v) or _.isPlainObject(v))
+        json
+    
+    /**
+     * Override to annotate with collapsed state and to kill off ignored options
+     * so they do not contribute their values when looking at form updates.
+     */
     render: ->
-        FieldView::render ...
-        @$el.addClass 'ignore'    if @get 'ignore'
+        return @remove() if @model.get 'ignore'
+        ChartOptionView.__super__.render ...
         @$el.addClass 'collapsed' if @isCollapsed
         this
     
     
+    
+    /* * * *  Option Collapsing  * * * */
+    
     /**
      * Sets the state of `isCollapsed` and updates the UI. If the state changed,
      * a `'change:collapse`` event will be fired.`
@@ -65,10 +88,34 @@ ChartOptionView = exports.ChartOptionView = FieldView.extend do # {{{
         @collapse not @$el.hasClass 'collapsed'
         this
     
+    
+    
+    /* * * *  Events  * * * */
+    
+    /**
+     * To prevent `toggleCollapsed()` from being called multiple times due to
+     * overlapping listeners, we're only looking for clicks on the collapsed header.
+     */
     onClick: (evt) ->
         target = $ evt.target
         @toggleCollapsed() if @$el.hasClass('collapsed') and not target.hasClass('close')
     
+    /**
+     * Propagate user input changes to the model, and upward to the parent view.
+     */
+    onChange: ->
+        if @type is 'boolean'
+            val = !! @$('.value').attr('checked')
+        else
+            val = @model.getParser() @$('.value').val()
+        
+        current = @model.getValue()
+        return if _.isEqual val, current
+        console.log "#this.onChange( #current -> #val )"
+        @model.setValue val, {+silent}
+        @trigger 'change', @model, this
+        # false
+    
     
 # }}}
 
@@ -77,44 +124,92 @@ ChartOptionView = exports.ChartOptionView = FieldView.extend do # {{{
 /**
  * @class View for configuring a chart type.
  */
-ChartOptionScaffold = exports.ChartOptionScaffold = Scaffold.extend do # {{{
-    __bind__       : <[ collapseAll expandAll ]>
+ChartOptionScaffold = exports.ChartOptionScaffold = BaseView.extend do # {{{
+    __bind__       : <[ addField ]>
     tagName        : 'form'
     className      : 'chart-options scaffold'
     template       : require 'kraken/template/chart-scaffold'
     
     collectionType : ChartOptionList
     subviewType    : ChartOptionView
-    fields         : '.fields'
     
     events:
         'click .options-filter-button'       : 'onFilterOptions'
         'click .collapse-all-options-button' : 'collapseAll'
         'click .expand-all-options-button'   : 'expandAll'
     
-    # GraphView will set this
-    ready  : false
     
     
     
     constructor: function ChartOptionScaffold
-        Scaffold ...
+        BaseView ...
     
     initialize : ->
         @render = _.debounce @render.bind(this), DEBOUNCE_RENDER
-        Scaffold::initialize ...
+        CollectionType = @collectionType
+        @model = (@collection or= new CollectionType)
+        ChartOptionScaffold.__super__.initialize ...
+        
+        @collection.on 'add',   @addField,      this
+        @collection.on 'reset', @onReset,       this
+        @on 'render',           @onRender,      this
     
     
-    render: ->
-        console.log "#this.render(ready=#{@ready}) -> .isotope()"
-        Scaffold::render ...
-        return this unless @ready
+    /**
+     * Bookkeeping for new ChartOptions, creating it a new subview and subscribing
+     * to its activity, and then rendering it.
+     * @returns {ChartOptionView} The Option's new view.
+     */
+    addField: (field) ->
+        @removeSubview field.view if field.view
+        
+        # avoid duplicating event propagation
+        field.off 'change:value',   @onChange,  this
+        
+        # propagate value-change events as key-value change events
+        field.on 'change:value',    @onChange,  this
+        
+        SubviewType = @subviewType
+        @addSubview view = new SubviewType model:field
+            .on 'change',           @onChange.bind(this, field)
+            .on 'change:collapse',  @render,    this
+        
+        @render() # WTF: hmm.
+        view
+    
+    
+    /* * * *  UI  * * * */
+    
+    /**
+     * Collapse all expanded subviews.
+     * @returns {false} Returns false so event-dispatchers don't propagate
+     *  the triggering event (usually a click or submit).
+     */
+    collapseAll: ->
+        _.invoke @subviews, 'collapse', true
+        false
+    
+    /**
+     * Expand all collapsed subviews.
+     * @returns {false} Returns false so event-dispatchers don't propagate
+     *  the triggering event (usually a click or submit).
+     */
+    expandAll: ->
+        _.invoke @subviews, 'collapse', false
+        false
+    
+    /**
+     * Reflow Isotope post-`render()`.
+     */
+    onRender: ->
+        # console.log "#this.onRender(ready=#{@ready}) -> .isotope()"
         
-        container = if @fields then @$ @fields else @$el
-        container
-            .addClass 'isotope'
-            .find '.chart-option.field' .addClass 'isotope-item'
-        container.isotope do
+        # The DOM doesn't calculate dimensions of elements that are not visible,
+        # which makes it impossible for Isotope to do its job.
+        return unless @$el.is ':visible'
+        
+        # Invoke Isotope to re-layout the option elements
+        @$ '.isotope' .isotope do
             # itemPositionDataEnabled : true
             itemSelector : '.chart-option.field'
             layoutMode   : 'masonry'
@@ -124,46 +219,51 @@ ChartOptionScaffold = exports.ChartOptionScaffold = Scaffold.extend do # {{{
             getSortData  : 
                 category: ($el) ->
                     $el.data 'model' .getCategory()
-        this
     
+    /**
+     * @returns {String} Selector representing the selected set of Option filters.
+     */
     getOptionsFilter: ->
         data = @$ '.options-filter-button.active' .toArray().map -> $ it .data()
         sel = data.reduce do
-            (sel, d) ->
-                sel += that if d.filter
-                sel
+            (sel, d) -> sel += if d.filter then that else ''
             ':not(.ignore)'
         sel
     
-    collapseAll: ->
-        _.invoke @subviews, 'collapse', true
-        # @renderSubviews()
-        false
     
-    expandAll: ->
-        _.invoke @subviews, 'collapse', false
-        # @renderSubviews()
-        false
     
+    /* * * *  Events  * * * */
     
     /**
-     * Add a ChartOption to this scaffold, rerendering the isotope
-     * layout after collapse events.
+     * Propagate change events from fields as if they were attribute changes.
+     * Note: `field` is bound to the handler 
      */
-    addField: (field) ->
-        view = Scaffold::addField ...
-        # view.on 'change:collapse render', @render, this
-        view.on 'change:collapse', @render, this
-        view
-    
-    toKV: ->
-        @collection.toKV ...
+    onChange: (field) ->
+        key   = field.get 'name'
+        value = field.getValue()
+        @trigger "change:#key", this, value, key, field
+        @trigger "change",      this, value, key, field
+        this
     
+    onReset: ->
+        # The collection has been reset, assume all subviews are
+        # invalid and rebuild them.
+        @removeAllSubviews()
+        @collection.each @addField
+        _.defer @render
     
     onFilterOptions: (evt) ->
         evt.preventDefault()
+        # Defer re-rendering until after we yield for the DOM to do its thang
         _.defer @render
     
+
+# Proxy collection methods
+<[ get at pluck invoke values toJSON toKVPairs toKV toURL ]>
+    .forEach (methodname) ->
+        ChartOptionScaffold::[methodname] = -> @collection[methodname].apply @collection, arguments
+
+
 # }}}
 
 
index 637477b..0831a29 100644 (file)
@@ -1,6 +1,11 @@
-_  = require 'kraken/util/underscore'
-{EventEmitter} = require 'events'
-{Parsers, ParserMixin}  = require 'kraken/util/parser'
+{ EventEmitter,
+} = require 'events'
+
+{ _, op,
+} = require 'kraken/util'
+{ Parsers, ParserMixin,
+} = require 'kraken/util/parser'
+
 
 
 /**
@@ -24,9 +29,9 @@ class exports.ChartTypeOption
             v = @spec[k]
             @[k] = v if v?
         @tags or= []
-        @parse = @chartType.getParser @type
+        @parseValue = @chartType.getParser @type
     
-    parse : Parsers.parseString
+    parseValue : Parsers.parseString
     
     isDefault: (v) ->
         # @default is v
@@ -119,7 +124,7 @@ class exports.ChartType extends EventEmitter
      * When implementing a ChartType, you can add or override parsers
      * merely by subclassing.
      */
-    this:: import ParserMixin::
+    ParserMixin this
     
     getParserFor: (name) ->
         @getParser @get(name).type
index ee302ab..2800782 100644 (file)
@@ -8,70 +8,35 @@ op = require 'kraken/util/op'
 ### Scaffold Models
 
 Field = exports.Field = BaseModel.extend do # {{{
-    idAttribute    : 'name'
     valueAttribute : 'value'
     
+    defaults: ->
+        name     : ''
+        type     : 'String'
+        default  : null
+        desc     : ''
+        include  : 'diff'
+        tags     : []
+        examples : []
+    
+    
     
     constructor: function Field
         BaseModel ...
     
     initialize: ->
         _.bindAll this, ...(_.functions this .filter -> _.startsWith(it, 'parse'))
+        @set 'id', @id = _.camelize @get 'name'
         @set 'value', @get('default'), {+silent} if not @has 'value'
-        # console.log "#this.initialize!"
-        # @on 'all', (evt) ~> console.log "#this.trigger(#evt)"
+        Field.__super__.initialize ...
+    
+    
+    
     
-    # Model defaults
-    defaults: ->
-        {
-            name     : ''
-            type     : 'String'
-            default  : null
-            desc     : ''
-            include  : 'diff'
-            tags     : []
-            examples : []
-        }
-    
-    
-    /* * *  Parsers  * * */
-    
-    getParser: (type) ->
-        # XXX: handle 'or' by returning an array of parsers?
-        type = _ (type or @get 'type').toLowerCase()
-        for t of <[ Integer Float Boolean Object Array Function ]>
-            if type.startsWith t.toLowerCase()
-                return @["parse#t"]
-        @parseString
-    
-    parseString: (v) ->
-        if v? then op.toStr v else null
-    
-    parseInteger: (v) ->
-        r = op.toInt v
-        unless isNaN r then r else null
-    
-    parseFloat: (v) ->
-        r = op.toFloat v
-        unless isNaN r then r else null
-    
-    parseBoolean: (v) ->
-        op.toBool v
-    
-    parseArray: (v) ->
-        if v then op.toObject v else null
-    
-    parseObject: (v) ->
-        if v then op.toObject v else null
-    
-    parseFunction: (fn) ->
-        if fn and _.startswith String(fn), 'function'
-            try eval "(#fn)" catch err then null
-        else
-            null
     
     
     /* * * Value Accessors * * */
+    
     getValue: (def) ->
         @getParser() @get @valueAttribute, def
     
@@ -84,32 +49,35 @@ Field = exports.Field = BaseModel.extend do # {{{
         @set @valueAttribute, val, options
     
     clearValue: ->