diff --git a/css/data-explorer.css b/css/data-explorer.css index 3023ebc1..6cc5b0a8 100644 --- a/css/data-explorer.css +++ b/css/data-explorer.css @@ -1,17 +1,24 @@ -.data-explorer .header .navigation, -.data-explorer .header .navigation li, -.data-explorer .header .pagination, -.data-explorer .header .pagination form +.recline-data-explorer .header .navigation, +.recline-data-explorer .header .navigation li, +.recline-data-explorer .header .pagination, +.recline-data-explorer .header .pagination form { display: inline; } -.data-explorer .header .navigation { +.recline-data-explorer .header .navigation { float: left; margin-left: 0; padding-left: 0; } +.recline-data-explorer .header .menu-right { + float: right; + margin-left: 5px; + padding-left: 5px; + border-left: solid 2px #ddd; +} + .header .recline-results-info { line-height: 28px; margin-left: 20px; @@ -31,31 +38,23 @@ float: left; } -.header .recline-query-editor .text-query input { - float: left; -} - -.recline-query-editor .text-query .btn-group { - display: inline; - float:left; - margin-left:-2px; -} - -.recline-query-editor .text-query .btn-group .dropdown-toggle { - -moz-border-radius:0px 3px 3px 0px; - -webkit-border-radius:0px 3px 3px 0px; - border-radius:0px 3px 3px 0px; -} - -.recline-query-editor .text-query .btn-group ul { - margin-left:-110px; +.header .recline-query-editor .pagination input { + width: 30px; + height: 18px; + padding: 2px 4px; + margin-top: -4px; } .header .recline-query-editor .pagination a { line-height: 26px; + padding: 0 6px; } -.data-view-container { +.header .recline-query-editor form button { + vertical-align: top; +} + +.recline-data-explorer .data-view-container { display: block; clear: both; } @@ -72,7 +71,7 @@ * Notifications *********************************************************/ -.notification-loader { +.recline-data-explorer .notification-loader { width: 18px; margin-left: 5px; background: url(images/small-spinner.gif) no-repeat; @@ -84,33 +83,33 @@ * Data Table *********************************************************/ -.data-table .btn-group .dropdown-toggle { +.recline-grid .btn-group .dropdown-toggle { padding: 1px 3px; line-height: auto; } -.data-table-container { +.recline-grid-container { overflow: auto; height: 550px; } -.data-table { +.recline-grid { border: 1px solid #ccc; width: 100%; } -.data-table td, .data-table th { +.recline-grid td, .recline-grid th { border-left: 1px solid #ccc; padding: 3px 4px; text-align: left; } -.data-table tr td:first-child, .data-table tr th:first-child { +.recline-grid tr td:first-child, .recline-grid tr th:first-child { width: 20px; } /* direct borrowing from twitter buttons */ -.data-table th, +.recline-grid th, .transform-column-view .expression-preview-table-wrapper th { background-color: #e6e6e6; @@ -149,27 +148,6 @@ display: none; } -.column-header-recon-stats-bar { - margin-top: 10px; - height: 4px; - background: #ddd; - border: 1px solid #ccc; - position: relative; - width: 100%; -} - -.column-header-recon-stats-matched { - position: absolute; - height: 100%; - background: #282; -} - -.column-header-recon-stats-blanks { - position: absolute; - height: 100%; - background: #3d3; -} - div.data-table-cell-content { line-height: 1.2; color: #222; @@ -197,7 +175,7 @@ a.data-table-cell-edit:hover { background-position: -25px 0px; } -.data-table td:hover .data-table-cell-edit { +.recline-grid td:hover .data-table-cell-edit { visibility: visible; } @@ -214,21 +192,6 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit { color: red; } -/* TODO: not sure the rest of this is needed */ -.data-table-cell-editor, .data-table-topic-popup { - overflow: auto; - border: 1px solid #bcf; - background: #e3e9ff; - padding: 5px; - -moz-border-radius: 5px; - -webkit-border-radius: 5px; - border-radius: 5px; -} - -.data-table-topic-popup-header { - padding: 0 0 5px; -} - .data-table-cell-editor-editor { overflow: hidden; display: block; @@ -258,28 +221,6 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit { color: #999; } -ul.sorting-dialog-blank-error-positions { - margin: 0; - padding: 5px; - height: 10em; - border: 1px solid #ccc; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; -} - -ul.sorting-dialog-blank-error-positions > li { - display: block; - border: 1px solid #ccc; - background: #eee; - padding: 5px; - margin: 2px; - cursor: move; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; -} - /********************************************************** * Dialogs @@ -441,8 +382,8 @@ td.expression-preview-value { * Read-only mode *********************************************************/ -.read-only .no-hidden .data-table tr td:first-child, -.read-only .no-hidden .data-table tr th:first-child +.read-only .no-hidden .recline-grid tr td:first-child, +.read-only .no-hidden .recline-grid tr th:first-child { display: none; } diff --git a/docs/model.html b/docs/model.html index 61b73e8f..2a21273c 100644 --- a/docs/model.html +++ b/docs/model.html @@ -94,36 +94,79 @@ also returned.

});

A Document (aka Row)

A single entry or row in the dataset

my.Document = Backbone.Model.extend({
-  __type__: 'Document'
-});

A Backbone collection of Documents

my.DocumentList = Backbone.Collection.extend({
+  __type__: 'Document',
+  initialize: function() {
+    _.bindAll(this, 'getFieldValue');
+  },

getFieldValue

+ +

For the provided Field get the corresponding rendered computed data value +for this document.

  getFieldValue: function(field) {
+    var val = this.get(field.id);
+    if (field.deriver) {
+      val = field.deriver(val, field, this);
+    }
+    if (field.renderer) {
+      val = field.renderer(val, field, this);
+    }
+    return val;
+  }
+});

A Backbone collection of Documents

my.DocumentList = Backbone.Collection.extend({
   __type__: 'DocumentList',
   model: my.Document
-});

A Field (aka Column) on a Dataset

+});

A Field (aka Column) on a Dataset

-

Following attributes as standard:

+

Following (Backbone) attributes as standard:

my.Field = Backbone.Model.extend({
-  defaults: {
-    id: null,
+
  • label: (optional: defaults to id) the visible label used for this field
  • +
  • type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on http://www.elasticsearch.org/guide/reference/mapping/
  • +
  • format: (optional) used to indicate how the data should be formatted. For example: +
    • type=date, format=yyyy-mm-dd
    • +
    • type=float, format=percentage
    • +
    • type=float, format='###,###.##'
  • +
  • is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
  • + + +

    Following additional instance properties:

    + +

    @property {Function} renderer: a function to render the data for this field. +Signature: function(value, field, doc) where value is the value of this +cell, field is corresponding field object and document is the document +object. Note that implementing functions can ignore arguments (e.g. +function(value) would be a valid formatter function).

    + +

    @property {Function} deriver: a function to derive/compute the value of data +in this field as a function of this field's value (if any) and the current +document, its signature and behaviour is the same as for renderer. Use of +this function allows you to define an entirely new value for data in this +field. This provides support for a) 'derived/computed' fields: i.e. fields +whose data are functions of the data in other fields b) transforming the +value of this field prior to rendering.

    my.Field = Backbone.Model.extend({

    defaults - define default values

      defaults: {
         label: null,
    -    type: 'String'
    -  },
    -  initialize: function(data) {

    if a hash not passed in the first argument throw error

        if ('0' in data) {
    +    type: 'string',
    +    format: null,
    +    is_derived: false
    +  },

    initialize

    + +

    @param {Object} data: standard Backbone model attributes

    + +

    @param {Object} options: renderer and/or deriver functions.

      initialize: function(data, options) {

    if a hash not passed in the first argument throw error

        if ('0' in data) {
           throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
         }
         if (this.attributes.label == null) {
           this.set({label: this.id});
         }
    +    if (options) {
    +      this.renderer = options.renderer;
    +      this.deriver = options.deriver;
    +    }
       }
     });
     
     my.FieldList = Backbone.Collection.extend({
       model: my.Field
    -});

    Query

    +});

    Query

    Query instances encapsulate a query to the backend (see query method on backend). Useful both @@ -179,10 +222,10 @@ execution.

    return { size: 100 , from: 0 - , facets: {}

    http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html -, filter: {}

      

    list of simple filters which will be add to 'add' filter of filter

          , filters: []
    +      , facets: {}

    http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html +, filter: {}

          , filters: []
         }
    -  },

    addTermFilter

    + },

    addTermFilter

    Set (update or add) a terms filter to filters

    @@ -191,13 +234,23 @@ execution.

    var filter = { term: {} }; filter.term[fieldId] = value; filters.push(filter); - this.set({filters: filters});

    change does not seem to be triggered ...

        this.trigger('change');
    -  },

    addFacet

    + this.set({filters: filters});

    change does not seem to be triggered automatically

        if (value) {
    +      this.trigger('change');
    +    } else {

    adding a new blank filter and do not want to trigger a new query

          this.trigger('change:filters:new-blank');
    +    }
    +  },

    removeFilter

    + +

    Remove a filter from filters at index filterIndex

      removeFilter: function(filterIndex) {
    +    var filters = this.get('filters');
    +    filters.splice(filterIndex, 1);
    +    this.set({filters: filters});
    +    this.trigger('change');
    +  },

    addFacet

    Add a Facet to this query

    See http://www.elasticsearch.org/guide/reference/api/search/facets/

      addFacet: function(fieldId) {
    -    var facets = this.get('facets');

    Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

        if (_.contains(_.keys(facets), fieldId)) {
    +    var facets = this.get('facets');

    Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

        if (_.contains(_.keys(facets), fieldId)) {
           return;
         }
         facets[fieldId] = {
    @@ -206,7 +259,7 @@ execution.

    this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); } -});

    A Facet (Result)

    +});

    A Facet (Result)

    Object to store Facet information, that is summary information (e.g. values and counts) about a field obtained by some faceting method on the @@ -253,9 +306,9 @@ key used to specify this facet in the facet query):

    terms: [] } } -});

    A Collection/List of Facets

    my.FacetList = Backbone.Collection.extend({
    +});

    A Collection/List of Facets

    my.FacetList = Backbone.Collection.extend({
       model: my.Facet
    -});

    Backend registry

    +});

    Backend registry

    Backends will register themselves by id into this registry

    my.backends = {};
     
    diff --git a/docs/view-flot-graph.html b/docs/view-flot-graph.html
    index 7ef5b8eb..cc2893c3 100644
    --- a/docs/view-flot-graph.html
    +++ b/docs/view-flot-graph.html
    @@ -1,4 +1,6 @@
    -      view-flot-graph.js           

    view-flot-graph.js

    this.recline = this.recline || {};
    +      view-flot-graph.js           

    view-flot-graph.js

    /*jshint multistr:true */
    +
    +this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
     
     (function($, my) {

    Graph view for a Dataset using Flot graphing library.

    diff --git a/docs/view-grid.html b/docs/view-grid.html index eb67d2e1..9b058bdd 100644 --- a/docs/view-grid.html +++ b/docs/view-grid.html @@ -1,21 +1,17 @@ - view-grid.js

    view-grid.js

    this.recline = this.recline || {};
    +      view-grid.js           

    view-grid.js

    /*jshint multistr:true */
    +
    +this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
     
     (function($, my) {

    DataGrid

    Provides a tabular view on a Dataset.

    -

    Initialize it with a recline.Dataset object.

    - -

    Additional options passed in second arguments. Options:

    - -
      -
    • cellRenderer: function used to render individual cells. See DataGridRow for more.
    • -
    my.DataGrid = Backbone.View.extend({
    +

    Initialize it with a recline.Model.Dataset.

    my.DataGrid = Backbone.View.extend({
       tagName:  "div",
    -  className: "data-table-container",
    +  className: "recline-grid-container",
     
    -  initialize: function(modelEtc, options) {
    +  initialize: function(modelEtc) {
         var self = this;
         this.el = $(this.el);
         _.bindAll(this, 'render');
    @@ -24,7 +20,6 @@
         this.model.currentDocuments.bind('remove', this.render);
         this.state = {};
         this.hiddenFields = [];
    -    this.options = options;
       },
     
       events: {
    @@ -67,6 +62,9 @@ Column and row menus

    facet: function() { self.model.queryState.addFacet(self.state.currentColumn); }, + filter: function() { + self.model.queryState.addTermFilter(self.state.currentColumn, ''); + }, transform: function() { self.showTransformDialog('transform') }, sortAsc: function() { self.setColumnSort('asc') }, sortDesc: function() { self.setColumnSort('desc') }, @@ -135,7 +133,7 @@ from DOM) while id may be int

    },

    ======================================================

    Templating

      template: ' \
    -    <table class="data-table table-striped table-condensed" cellspacing="0"> \
    +    <table class="recline-grid table-striped table-condensed" cellspacing="0"> \
           <thead> \
             <tr> \
               {{#notEmpty}} \
    @@ -154,9 +152,13 @@ from DOM) while id may be int

    <a class="btn dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i><span class="caret"></span></a> \ <ul class="dropdown-menu data-table-menu pull-right"> \ <li><a data-action="facet" href="JavaScript:void(0);">Facet on this Field</a></li> \ + <li><a data-action="filter" href="JavaScript:void(0);">Text Filter</a></li> \ + <li class="divider"></li> \ <li><a data-action="sortAsc" href="JavaScript:void(0);">Sort ascending</a></li> \ <li><a data-action="sortDesc" href="JavaScript:void(0);">Sort descending</a></li> \ + <li class="divider"></li> \ <li><a data-action="hideColumn" href="JavaScript:void(0);">Hide this column</a></li> \ + <li class="divider"></li> \ <li class="write-op"><a data-action="bulkEdit" href="JavaScript:void(0);">Transform...</a></li> \ </ul> \ </div> \ @@ -188,9 +190,7 @@ from DOM) while id may be int

    model: doc, el: tr, fields: self.fields - }, - self.options - ); + }); newView.render(); }); this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); @@ -202,16 +202,6 @@ from DOM) while id may be int

    In addition you must pass in a FieldList in the constructor options. This should be list of fields for the DataGrid.

    -

    Additional options can be passed in a second hash argument. Options:

    - -
      -
    • cellRenderer: function to render cells. Signature: function(value, -field, doc) where value is the value of this cell, field is -corresponding field object and document is the document object. Note -that implementing functions can ignore arguments (e.g. -function(value) would be a valid cellRenderer function).
    • -
    -

    Example:

    @@ -219,21 +209,11 @@ var row = new DataGridRow({
       model: dataset-document,
         el: dom-element,
         fields: mydatasets.fields // a FieldList object
    -  }, {
    -    cellRenderer: my-cell-renderer-function 
    -  }
    -);
    +  });
     
    my.DataGridRow = Backbone.View.extend({
    -  initialize: function(initData, options) {
    +  initialize: function(initData) {
         _.bindAll(this, 'render');
         this._fields = initData.fields;
    -    if (options && options.cellRenderer) {
    -      this._cellRenderer = options.cellRenderer;
    -    } else {
    -      this._cellRenderer = function(value) {
    -        return value;
    -      }
    -    }
         this.el = $(this.el);
         this.model.bind('change', this.render);
       },
    @@ -268,7 +248,7 @@ var row = new DataGridRow({
         var cellData = this._fields.map(function(field) {
           return {
             field: field.id,
    -        value: self._cellRenderer(doc.get(field.id), field, doc)
    +        value: doc.getFieldValue(field)
           }
         })
         return { id: this.id, cells: cellData }
    diff --git a/docs/view.html b/docs/view.html
    index daf29400..0bebc221 100644
    --- a/docs/view.html
    +++ b/docs/view.html
    @@ -1,4 +1,5 @@
    -      view.js           

    view.js

    this.recline = this.recline || {};
    +      view.js           
    varqueryEditor=newmy.QueryEditor({model:this.model.queryState}); - this.el.find('.header').append(queryEditor.el); - varqueryFacetEditor=newmy.FacetViewer({ + this.el.find('.query-editor-here').append(queryEditor.el); + varfilterEditor=newmy.FilterEditor({ + model:this.model.queryState + }); + this.$filterEditor=filterEditor.el; + this.el.find('.header').append(filterEditor.el); + varfacetViewer=newmy.FacetViewer({model:this.model}); - this.el.find('.header').append(queryFacetEditor.el); + this.$facetViewer=facetViewer.el; + this.el.find('.header').append(facetViewer.el);},setupRouting:function(){ @@ -188,10 +204,19 @@ note this.model and dataset returned are the same

    view.view.el.hide();}}); + }, + + onMenuClick:function(e){ + e.preventDefault(); + varaction=$(e.target).attr('data-action'); + if(action==='filters'){ + this.$filterEditor.show(); + }elseif(action==='facets'){ + this.$facetViewer.show(); + }}}); - my.QueryEditor=Backbone.View.extend({className:'recline-query-editor',template:' \ @@ -199,28 +224,21 @@ note this.model and dataset returned are the same

    <div class="input-prepend text-query"> \ <span class="add-on"><i class="icon-search"></i></span> \ <input type="text" name="q" value="{{q}}" class="span2" placeholder="Search data ..." class="search-query" /> \ - <div class="btn-group menu"> \ - <a class="btn dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i><span class="caret"></span></a> \ - <ul class="dropdown-menu"> \ - <li><a data-action="size" href="">Number of items to show ({{size}})</a></li> \ - <li><a data-action="from" href="">Show from ({{from}})</a></li> \ - </ul> \ - </div> \ </div> \ <div class="pagination"> \ <ul> \ <li class="prev action-pagination-update"><a href="">&laquo;</a></li> \ - <li class="active"><a>{{from}} &ndash; {{to}}</a></li> \ + <li class="active"><a><input name="from" type="text" value="{{from}}" /> &ndash; <input name="to" type="text" value="{{to}}" /> </a></li> \ <li class="next action-pagination-update"><a href="">&raquo;</a></li> \ </ul> \ </div> \ + <button type="submit" class="btn">Go &raquo;</button> \ </form> \ ',events:{'submit form':'onFormSubmit','click .action-pagination-update':'onPaginationUpdate' - ,'click .menu li a':'onMenuItemClick'},initialize:function(){ @@ -232,7 +250,9 @@ note this.model and dataset returned are the same

    onFormSubmit:function(e){e.preventDefault();varquery=this.el.find('.text-query input').val(); - this.model.set({q:query}); + varnewFrom=parseInt(this.el.find('input[name="from"]').val()); + varnewSize=parseInt(this.el.find('input[name="to"]').val())-newFrom; + this.model.set({size:newSize,from:newFrom,q:query});},onPaginationUpdate:function(e){e.preventDefault(); @@ -244,20 +264,6 @@ note this.model and dataset returned are the same

    }this.model.set({from:newFrom});}, - onMenuItemClick:function(e){ - e.preventDefault(); - varattrName=$(e.target).attr('data-action'); - varmsg=_.template('New value (<%= value %>)', - {value:this.model.get(attrName)} - ); - varnewValue=prompt(msg); - if(newValue){ - newValue=parseInt(newValue); - varupdate={}; - update[attrName]=newValue; - this.model.set(update); - } - },render:function(){vartmplData=this.model.toJSON();tmplData.to=this.model.get('from')+this.model.get('size'); @@ -266,6 +272,101 @@ note this.model and dataset returned are the same

    }}); +my.FilterEditor=Backbone.View.extend({ + className:'recline-filter-editor well', + template:' \ + <a class="close js-hide" href="#">&times;</a> \ + <div class="row filters"> \ + <div class="span1"> \ + <h3>Filters</h3> \ + </div> \ + <div class="span11"> \ + <form class="form-horizontal"> \ + <div class="row"> \ + <div class="span6"> \ + {{#termFilters}} \ + <div class="control-group filter-term filter" data-filter-id={{id}}> \ + <label class="control-label" for="">{{label}}</label> \ + <div class="controls"> \ + <div class="input-append"> \ + <input type="text" value="{{value}}" name="{{fieldId}}" class="span4" data-filter-field="{{fieldId}}" data-filter-id="{{id}}" data-filter-type="term" /> \ + <a class="btn js-remove-filter"><i class="icon-remove"></i></a> \ + </div> \ + </div> \ + </div> \ + {{/termFilters}} \ + </div> \ + <div class="span4"> \ + <p>To add a filter use the column menu in the grid view.</p> \ + <button type="submit" class="btn">Update</button> \ + </div> \ + </form> \ + </div> \ + </div> \ + ', + events:{ + 'click .js-hide':'onHide', + 'click .js-remove-filter':'onRemoveFilter', + 'submit form':'onTermFiltersUpdate' + }, + initialize:function(){ + this.el=$(this.el); + _.bindAll(this,'render'); + this.model.bind('change',this.render); + this.model.bind('change:filters:new-blank',this.render); + this.render(); + }, + render:function(){ + vartmplData=$.extend(true,{},this.model.toJSON()); <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="icon-chevron-down"></i> {{id}} {{label}}</a> \ <ul class="facet-items dropdown-menu"> \ {{#terms}} \ - <li><input type="checkbox" class="facet-choice js-facet-filter" value="{{term}}" name="{{term}}" /> <label for="{{term}}">{{term}} ({{count}})</label></li> \ + <li><a class="facet-choice js-facet-filter" data-value="{{term}}">{{term}} ({{count}})</a></li> \ {{/terms}} \ </ul> \ </div> \ @@ -289,7 +390,7 @@ note this.model and dataset returned are the same

    events:{'click .js-hide':'onHide', - 'change .js-facet-filter':'onFacetFilter' + 'click .js-facet-filter':'onFacetFilter'},initialize:function(model){_.bindAll(this,'render'); @@ -304,7 +405,7 @@ note this.model and dataset returned are the same

    fields:this.model.fields.toJSON()};vartemplated=$.mustache(this.template,tmplData); - this.el.html(templated);e.preventDefault();this.el.hide();}, - onFacetFilter:function(e){query:parsed[2]||''}} -}if(q&&q.length&&q[0]==='?'){q=q.slice(1);} - while(e=r.exec(q)){my.getNewHashForQueryString=function(queryParams){varqueryPart=my.composeQueryString(queryParams); - if(window.location.hash){my.setHashQueryString=function(queryParams){window.location.hash=my.getNewHashForQueryString(queryParams); -} {{/loader}} \ </div>';var_templated=$.mustache(_template,tmplData); - _templated=$(_templated).appendTo($('.data-explorer .alert-messages')); + _templated=$(_templated).appendTo($('.recline-data-explorer .alert-messages'));if(!options.persist){setTimeout(function(){$(_templated).fadeOut(1000,function(){ @@ -401,10 +503,10 @@ note this.model and dataset returned are the same

    });},1000);} -}

    view.js

    /*jshint multistr:true */
    +this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
     
     (function($, my) {

    DataExplorer

    @@ -53,7 +54,7 @@ operate in read-only mode (hiding all editing options).

    NB: the element already being in the DOM is important for rendering of FlotGraph subview.

    my.DataExplorer = Backbone.View.extend({
       template: ' \
    -  <div class="data-explorer"> \
    +  <div class="recline-data-explorer"> \
         <div class="alert-messages"></div> \
         \
         <div class="header"> \
    @@ -65,6 +66,12 @@ FlotGraph subview.

    <div class="recline-results-info"> \ Results found <span class="doc-count">{{docCount}}</span> \ </div> \ + <div class="menu-right"> \ + <a href="#" class="btn" data-action="filters">Filters</a> \ + <a href="#" class="btn" data-action="facets">Facets</a> \ + </div> \ + <div class="query-editor-here" style="display:inline;"></div> \ + <div class="clearfix"></div> \ </div> \ <div class="data-view-container"></div> \ <div class="dialog-overlay" style="display: none; z-index: 101; ">&nbsp;</div> \ @@ -75,6 +82,9 @@ FlotGraph subview.

    </div> \ </div> \ ', + events: { + 'click .menu-right a': 'onMenuClick' + }, initialize: function(options) { var self = this; @@ -158,11 +168,17 @@ note this.model and dataset returned are the same

    we will use idx in list as there id ...

        tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
    +      filter.id = idx;
    +      return filter;
    +    });
    +    tmplData.termFilters = _.filter(tmplData.filters, function(filter) {
    +      return filter.term !== undefined;
    +    });
    +    tmplData.termFilters = _.map(tmplData.termFilters, function(filter) {
    +      var fieldId = _.keys(filter.term)[0];
    +      return {
    +        id: filter.id,
    +        fieldId: fieldId,
    +        label: fieldId,
    +        value: filter.term[fieldId]
    +      }
    +    });
    +    var out = $.mustache(this.template, tmplData);
    +    this.el.html(out);

    are there actually any facets to show?

        if (this.model.get('filters').length > 0) {
    +      this.el.show();
    +    } else {
    +      this.el.hide();
    +    }
    +  },
    +  onHide: function(e) {
    +    e.preventDefault();
    +    this.el.hide();
    +  },
    +  onRemoveFilter: function(e) {
    +    e.preventDefault();
    +    var $target = $(e.target);
    +    var filterId = $target.closest('.filter').attr('data-filter-id');
    +    this.model.removeFilter(filterId);
    +  },
    +  onTermFiltersUpdate: function(e) {
    +   var self = this;
    +    e.preventDefault();
    +    var filters = self.model.get('filters');
    +    var $form = $(e.target);
    +    _.each($form.find('input'), function(input) {
    +      var $input = $(input);
    +      var filterIndex = parseInt($input.attr('data-filter-id'));
    +      var value = $input.val();
    +      var fieldId = $input.attr('data-filter-field');
    +      filters[filterIndex].term[fieldId] = value;
    +    });
    +    self.model.set({filters: filters});
    +    self.model.trigger('change');
    +  }
    +});
    +
     my.FacetViewer = Backbone.View.extend({
       className: 'recline-facet-viewer well', 
       template: ' \
    @@ -279,7 +380,7 @@ note this.model and dataset returned are the same

    are there actually any facets to show?

        if (this.model.facets.length > 0) {
    +    this.el.html(templated);

    are there actually any facets to show?

        if (this.model.facets.length > 0) {
           this.el.show();
         } else {
           this.el.hide();
    @@ -314,14 +415,15 @@ note this.model and dataset returned are the same

    todo: uncheck

        var $checkbox = $(e.target);
    -    var fieldId = $checkbox.closest('.facet-summary').attr('data-facet');
    -    var value = $checkbox.val();
    +  onFacetFilter: function(e) {
    +    var $target= $(e.target);
    +    var fieldId = $target.closest('.facet-summary').attr('data-facet');
    +    var value = $target.attr('data-value');
         this.model.queryState.addTermFilter(fieldId, value);
       }
     });
     
    -/* ========================================================== */

    Miscellaneous Utilities

    var urlPathRegex = /^([^?]+)(\?.*)?/;

    Parse the Hash section of a URL into path and query string

    my.parseHashUrl = function(hashUrl) {
    +/* ========================================================== */

    Miscellaneous Utilities

    var urlPathRegex = /^([^?]+)(\?.*)?/;

    Parse the Hash section of a URL into path and query string

    my.parseHashUrl = function(hashUrl) {
       var parsed = urlPathRegex.exec(hashUrl);
       if (parsed == null) {
         return {};
    @@ -331,7 +433,7 @@ note this.model and dataset returned are the same

    Parse a URL query string (?xyz=abc...) into a dictionary.

    my.parseQueryString = function(q) {
    +}

    Parse a URL query string (?xyz=abc...) into a dictionary.

    my.parseQueryString = function(q) {
       if (!q) {
         return {};
       }
    @@ -344,13 +446,13 @@ note this.model and dataset returned are the same

    TODO: have values be array as query string allow repetition of keys

        urlParams[d(e[1])] = d(e[2]);
    +  while (e = r.exec(q)) {

    TODO: have values be array as query string allow repetition of keys

        urlParams[d(e[1])] = d(e[2]);
       }
       return urlParams;
    -}

    Parse the query string out of the URL hash

    my.parseHashQueryString = function() {
    +}

    Parse the query string out of the URL hash

    my.parseHashQueryString = function() {
       q = my.parseHashUrl(window.location.hash).query;
       return my.parseQueryString(q);
    -}

    Compse a Query String

    my.composeQueryString = function(queryParams) {
    +}

    Compse a Query String

    my.composeQueryString = function(queryParams) {
       var queryString = '?';
       var items = [];
       $.each(queryParams, function(key, value) {
    @@ -362,7 +464,7 @@ note this.model and dataset returned are the same

    slice(1) to remove # at start

        return window.location.hash.split('?')[0].slice(1) + queryPart;
    +  if (window.location.hash) {

    slice(1) to remove # at start

        return window.location.hash.split('?')[0].slice(1) + queryPart;
       } else {
         return queryPart;
       }
    @@ -370,7 +472,7 @@ note this.model and dataset returned are the same

    notify

    +}

    notify

    Create a notification (a div.alert in div.alert-messsages) using provide messages and options. Options are:

    @@ -393,7 +495,7 @@ note this.model and dataset returned are the same

    clearNotifications

    +}

    clearNotifications

    Clear all existing notifications

    my.clearNotifications = function() {
    -  var $notifications = $('.data-explorer .alert-messages .alert');
    +  var $notifications = $('.recline-data-explorer .alert-messages .alert');
       $notifications.remove();
     }
     
    diff --git a/recline.js b/recline.js
    index 73860da4..11c3f779 100644
    --- a/recline.js
    +++ b/recline.js
    @@ -72,17 +72,33 @@ this.recline.Model = this.recline.Model || {};
     
     (function($, my) {
     
    -// ## A Dataset model
    +// ## A Dataset model
     //
     // A model has the following (non-Backbone) attributes:
     //
    -// * fields: (aka columns) is a FieldList listing all the fields on this
    -//   Dataset (this can be set explicitly, or, will be set by Dataset.fetch() or Dataset.query()
    -// * currentDocuments: a DocumentList containing the Documents we have
    -//   currently loaded for viewing (you update currentDocuments by calling query)
    -// * docCount: total number of documents in this dataset
    +// @property {FieldList} fields: (aka columns) is a `FieldList` listing all the
    +// fields on this Dataset (this can be set explicitly, or, will be set by
    +// Dataset.fetch() or Dataset.query()
    +//
    +// @property {DocumentList} currentDocuments: a `DocumentList` containing the
    +// Documents we have currently loaded for viewing (updated by calling query
    +// method)
    +//
    +// @property {number} docCount: total number of documents in this dataset
    +//
    +// @property {Backend} backend: the Backend (instance) for this Dataset
    +//
    +// @property {Query} queryState: `Query` object which stores current
    +// queryState. queryState may be edited by other components (e.g. a query
    +// editor view) changes will trigger a Dataset query.
    +//
    +// @property {FacetList} facets: FacetList object containing all current
    +// Facets.
     my.Dataset = Backbone.Model.extend({
       __type__: 'Dataset',
    +  // ### initialize
    +  // 
    +  // Sets up instance properties (see above)
       initialize: function(model, backend) {
         _.bindAll(this, 'query');
         this.backend = backend;
    @@ -154,11 +170,29 @@ my.Dataset = Backbone.Model.extend({
       }
     });
     
    -// ## A Document (aka Row)
    +// ## A Document (aka Row)
     // 
     // A single entry or row in the dataset
     my.Document = Backbone.Model.extend({
    -  __type__: 'Document'
    +  __type__: 'Document',
    +  initialize: function() {
    +    _.bindAll(this, 'getFieldValue');
    +  },
    +
    +  // ### getFieldValue
    +  //
    +  // For the provided Field get the corresponding rendered computed data value
    +  // for this document.
    +  getFieldValue: function(field) {
    +    var val = this.get(field.id);
    +    if (field.deriver) {
    +      val = field.deriver(val, field, this);
    +    }
    +    if (field.renderer) {
    +      val = field.renderer(val, field, this);
    +    }
    +    return val;
    +  }
     });
     
     // ## A Backbone collection of Documents
    @@ -167,29 +201,59 @@ my.DocumentList = Backbone.Collection.extend({
       model: my.Document
     });
     
    -// ## A Field (aka Column) on a Dataset
    +// ## A Field (aka Column) on a Dataset
     // 
    -// Following attributes as standard:
    +// Following (Backbone) attributes as standard:
     //
    -//  * id: a unique identifer for this field- usually this should match the key in the documents hash
    -//  * label: the visible label used for this field
    -//  * type: the type of the data
    +// * id: a unique identifer for this field- usually this should match the key in the documents hash
    +// * label: (optional: defaults to id) the visible label used for this field
    +// * type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on 
    +// * format: (optional) used to indicate how the data should be formatted. For example:
    +//   * type=date, format=yyyy-mm-dd
    +//   * type=float, format=percentage
    +//   * type=float, format='###,###.##'
    +// * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
    +// 
    +// Following additional instance properties:
    +// 
    +// @property {Function} renderer: a function to render the data for this field.
    +// Signature: function(value, field, doc) where value is the value of this
    +// cell, field is corresponding field object and document is the document
    +// object. Note that implementing functions can ignore arguments (e.g.
    +// function(value) would be a valid formatter function).
    +// 
    +// @property {Function} deriver: a function to derive/compute the value of data
    +// in this field as a function of this field's value (if any) and the current
    +// document, its signature and behaviour is the same as for renderer.  Use of
    +// this function allows you to define an entirely new value for data in this
    +// field. This provides support for a) 'derived/computed' fields: i.e. fields
    +// whose data are functions of the data in other fields b) transforming the
    +// value of this field prior to rendering.
     my.Field = Backbone.Model.extend({
    +  // ### defaults - define default values
       defaults: {
    -    id: null,
         label: null,
    -    type: 'String'
    +    type: 'string',
    +    format: null,
    +    is_derived: false
       },
    -  // In addition to normal backbone initialization via a Hash you can also
    -  // just pass a single argument representing id to the ctor
    -  initialize: function(data) {
    -    // if a hash not passed in the first argument is set as value for key 0
    +  // ### initialize
    +  //
    +  // @param {Object} data: standard Backbone model attributes
    +  //
    +  // @param {Object} options: renderer and/or deriver functions.
    +  initialize: function(data, options) {
    +    // if a hash not passed in the first argument throw error
         if ('0' in data) {
           throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
         }
         if (this.attributes.label == null) {
           this.set({label: this.id});
         }
    +    if (options) {
    +      this.renderer = options.renderer;
    +      this.deriver = options.deriver;
    +    }
       }
     });
     
    @@ -197,30 +261,99 @@ my.FieldList = Backbone.Collection.extend({
       model: my.Field
     });
     
    -// ## A Query object storing Dataset Query state
    +// ## Query
    +//
    +// Query instances encapsulate a query to the backend (see query method on backend). Useful both
    +// for creating queries and for storing and manipulating query state -
    +// e.g. from a query editor).
    +//
    +// **Query Structure and format**
    +//
    +// Query structure should follow that of [ElasticSearch query
    +// language](http://www.elasticsearch.org/guide/reference/api/search/).
    +//
    +// **NB: It is up to specific backends how to implement and support this query
    +// structure. Different backends might choose to implement things differently
    +// or not support certain features. Please check your backend for details.**
    +//
    +// Query object has the following key attributes:
    +// 
    +//  * size (=limit): number of results to return
    +//  * from (=offset): offset into result set - http://www.elasticsearch.org/guide/reference/api/search/from-size.html
    +//  * sort: sort order - 
    +//  * query: Query in ES Query DSL 
    +//  * filter: See filters and Filtered Query
    +//  * fields: set of fields to return - http://www.elasticsearch.org/guide/reference/api/search/fields.html
    +//  * facets: TODO - see http://www.elasticsearch.org/guide/reference/api/search/facets/
    +// 
    +// Additions:
    +// 
    +//  * q: either straight text or a hash will map directly onto a [query_string
    +//  query](http://www.elasticsearch.org/guide/reference/query-dsl/query-string-query.html)
    +//  in backend
    +//
    +//   * Of course this can be re-interpreted by different backends. E.g. some
    +//   may just pass this straight through e.g. for an SQL backend this could be
    +//   the full SQL query
    +//
    +//  * filters: dict of ElasticSearch filters. These will be and-ed together for
    +//  execution.
    +// 
    +// **Examples**
    +// 
    +// 
    +// {
    +//    q: 'quick brown fox',
    +//    filters: [
    +//      { term: { 'owner': 'jones' } }
    +//    ]
    +// }
    +// 
    my.Query = Backbone.Model.extend({ defaults: function() { return { size: 100 , from: 0 , facets: {} - // http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html + // // , filter: {} - // list of simple filters which will be add to 'add' filter of filter , filters: [] } }, - // Set (update or add) a terms filter - // http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html + // #### addTermFilter + // + // Set (update or add) a terms filter to filters + // + // See addTermFilter: function(fieldId, value) { var filters = this.get('filters'); var filter = { term: {} }; filter.term[fieldId] = value; filters.push(filter); this.set({filters: filters}); - // change does not seem to be triggered ... + // change does not seem to be triggered automatically + if (value) { + this.trigger('change'); + } else { + // adding a new blank filter and do not want to trigger a new query + this.trigger('change:filters:new-blank'); + } + }, + // ### removeFilter + // + // Remove a filter from filters at index filterIndex + removeFilter: function(filterIndex) { + var filters = this.get('filters'); + filters.splice(filterIndex, 1); + this.set({filters: filters}); this.trigger('change'); }, + // ### addFacet + // + // Add a Facet to this query + // + // See addFacet: function(fieldId) { var facets = this.get('facets'); // Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field) @@ -236,18 +369,51 @@ my.Query = Backbone.Model.extend({ }); -// ## A Facet (Result) +// ## A Facet (Result) +// +// Object to store Facet information, that is summary information (e.g. values +// and counts) about a field obtained by some faceting method on the +// backend. +// +// Structure of a facet follows that of Facet results in ElasticSearch, see: +// +// +// Specifically the object structure of a facet looks like (there is one +// addition compared to ElasticSearch: the "id" field which corresponds to the +// key used to specify this facet in the facet query): +// +//
    +// {
    +//   "id": "id-of-facet",
    +//   // type of this facet (terms, range, histogram etc)
    +//   "_type" : "terms",
    +//   // total number of tokens in the facet
    +//   "total": 5,
    +//   // @property {number} number of documents which have no value for the field
    +//   "missing" : 0,
    +//   // number of facet values not included in the returned facets
    +//   "other": 0,
    +//   // term object ({term: , count: ...})
    +//   "terms" : [ {
    +//       "term" : "foo",
    +//       "count" : 2
    +//     }, {
    +//       "term" : "bar",
    +//       "count" : 2
    +//     }, {
    +//       "term" : "baz",
    +//       "count" : 1
    +//     }
    +//   ]
    +// }
    +// 
    my.Facet = Backbone.Model.extend({ defaults: function() { return { _type: 'terms', - // total number of tokens in the facet total: 0, - // number of facet values not included in the returned facets other: 0, - // number of documents which have no value for the field missing: 0, - // term object ({term: , count: ...}) terms: [] } } @@ -265,6 +431,8 @@ my.backends = {}; }(jQuery, this.recline.Model)); +/*jshint multistr:true */ + var util = function() { var templates = { transformActions: '
  • Global transform...
  • ' @@ -415,6 +583,8 @@ var util = function() { observeExit: observeExit }; }(); +/*jshint multistr:true */ + this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -767,6 +937,8 @@ my.FlotGraph = Backbone.View.extend({ })(jQuery, recline.View); +/*jshint multistr:true */ + this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -775,16 +947,12 @@ this.recline.View = this.recline.View || {}; // // Provides a tabular view on a Dataset. // -// Initialize it with a recline.Dataset object. -// -// Additional options passed in second arguments. Options: -// -// * cellRenderer: function used to render individual cells. See DataGridRow for more. +// Initialize it with a `recline.Model.Dataset`. my.DataGrid = Backbone.View.extend({ tagName: "div", - className: "data-table-container", + className: "recline-grid-container", - initialize: function(modelEtc, options) { + initialize: function(modelEtc) { var self = this; this.el = $(this.el); _.bindAll(this, 'render'); @@ -793,7 +961,6 @@ my.DataGrid = Backbone.View.extend({ this.model.currentDocuments.bind('remove', this.render); this.state = {}; this.hiddenFields = []; - this.options = options; }, events: { @@ -843,6 +1010,9 @@ my.DataGrid = Backbone.View.extend({ facet: function() { self.model.queryState.addFacet(self.state.currentColumn); }, + filter: function() { + self.model.queryState.addTermFilter(self.state.currentColumn, ''); + }, transform: function() { self.showTransformDialog('transform') }, sortAsc: function() { self.setColumnSort('asc') }, sortDesc: function() { self.setColumnSort('desc') }, @@ -915,7 +1085,7 @@ my.DataGrid = Backbone.View.extend({ // ====================================================== // #### Templating template: ' \ - \ +
    \ \ \ {{#notEmpty}} \ @@ -934,9 +1104,13 @@ my.DataGrid = Backbone.View.extend({ \ \ \ @@ -970,9 +1144,7 @@ my.DataGrid = Backbone.View.extend({ model: doc, el: tr, fields: self.fields - }, - self.options - ); + }); newView.render(); }); this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); @@ -986,14 +1158,6 @@ my.DataGrid = Backbone.View.extend({ // // In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the DataGrid. // -// Additional options can be passed in a second hash argument. Options: -// -// * cellRenderer: function to render cells. Signature: function(value, -// field, doc) where value is the value of this cell, field is -// corresponding field object and document is the document object. Note -// that implementing functions can ignore arguments (e.g. -// function(value) would be a valid cellRenderer function). -// // Example: // //
    @@ -1001,22 +1165,12 @@ my.DataGrid = Backbone.View.extend({
     //   model: dataset-document,
     //     el: dom-element,
     //     fields: mydatasets.fields // a FieldList object
    -//   }, {
    -//     cellRenderer: my-cell-renderer-function 
    -//   }
    -// );
    +//   });
     // 
    my.DataGridRow = Backbone.View.extend({ - initialize: function(initData, options) { + initialize: function(initData) { _.bindAll(this, 'render'); this._fields = initData.fields; - if (options && options.cellRenderer) { - this._cellRenderer = options.cellRenderer; - } else { - this._cellRenderer = function(value) { - return value; - } - } this.el = $(this.el); this.model.bind('change', this.render); }, @@ -1051,7 +1205,7 @@ my.DataGridRow = Backbone.View.extend({ var cellData = this._fields.map(function(field) { return { field: field.id, - value: self._cellRenderer(doc.get(field.id), field, doc) + value: doc.getFieldValue(field) } }) return { id: this.id, cells: cellData } @@ -1104,6 +1258,174 @@ my.DataGridRow = Backbone.View.extend({ }); })(jQuery, recline.View); +/*jshint multistr:true */ + +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { + +my.Map = Backbone.View.extend({ + + tagName: 'div', + className: 'data-map-container', + + latitudeFieldNames: ['lat','latitude'], + longitudeFieldNames: ['lon','longitude'], + geometryFieldNames: ['geom','the_geom','geometry','spatial'], + + //TODO: In case we want to change the default markers + /* + markerOptions: { + radius: 5, + color: 'grey', + fillColor: 'orange', + weight: 2, + opacity: 1, + fillOpacity: 1 + }, + */ + + template: ' \ +
    \ +
    \ +', + + initialize: function(options, config) { + var self = this; + + this.el = $(this.el); + this.render(); + this.model.bind('change', function() { + self._setupGeometryField(); + }); + + this.mapReady = false; + }, + + render: function() { + + var self = this; + + htmls = $.mustache(this.template, this.model.toTemplateJSON()); + $(this.el).html(htmls); + this.$map = this.el.find('.panel.map'); + + this.model.bind('query:done', function() { + if (!self.geomReady){ + self._setupGeometryField(); + } + + if (!self.mapReady){ + self._setupMap(); + } + self.redraw() + }); + + return this; + }, + + redraw: function(){ + + var self = this; + + if (this.geomReady){ + if (this.model.currentDocuments.length > 0){ + this.features.clearLayers(); + var bounds = new L.LatLngBounds(); + + this.model.currentDocuments.forEach(function(doc){ + var feature = self._getGeometryFromDocument(doc); + if (feature){ + // Build popup contents + // TODO: mustache? + html = '' + for (key in doc.attributes){ + html += '
    ' + key + ': '+ doc.attributes[key] + '
    ' + } + feature.properties = {popupContent: html}; + + self.features.addGeoJSON(feature); + + // TODO: bounds and center map + } + }); + } + } + }, + + _getGeometryFromDocument: function(doc){ + if (this.geomReady){ + if (this._geomFieldName){ + // We assume that the contents of the field are a valid GeoJSON object + return doc.attributes[this._geomFieldName]; + } else if (this._lonFieldName && this._latFieldName){ + // We'll create a GeoJSON like point object from the two lat/lon fields + return { + type: 'Point', + coordinates: [ + doc.attributes[this._lonFieldName], + doc.attributes[this._latFieldName], + ] + } + } + return null; + } + }, + + _setupGeometryField: function(){ + var geomField, latField, lonField; + + // Check if there is a field with GeoJSON geometries or alternatively, + // two fields with lat/lon values + this._geomFieldName = this._checkField(this.geometryFieldNames); + this._latFieldName = this._checkField(this.latitudeFieldNames); + this._lonFieldName = this._checkField(this.longitudeFieldNames); + + // TODO: Allow users to choose the fields + + this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName)); + }, + + _checkField: function(fieldNames){ + var field; + for (var i = 0; i < fieldNames.length; i++){ + field = this.model.fields.get(fieldNames[i]); + if (field) return field.id; + } + return null; + }, + + _setupMap: function(){ + + this.map = new L.Map(this.$map.get(0)); + + // MapQuest OpenStreetMap base map + var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png"; + var osmAttribution = 'Map data © 2011 OpenStreetMap contributors, Tiles Courtesy of MapQuest'; + var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'}); + this.map.addLayer(bg); + + // Layer to hold the features + this.features = new L.GeoJSON(); + this.features.on('featureparse', function (e) { + if (e.properties && e.properties.popupContent){ + e.layer.bindPopup(e.properties.popupContent); + } + }); + this.map.addLayer(this.features); + + this.map.setView(new L.LatLng(0, 0), 2); + + this.mapReady = true; + } + + }); + +})(jQuery, recline.View); + +/*jshint multistr:true */ + this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -1310,6 +1632,7 @@ my.ColumnTransform = Backbone.View.extend({ }); })(jQuery, recline.View); +/*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -1365,7 +1688,7 @@ this.recline.View = this.recline.View || {}; // FlotGraph subview. my.DataExplorer = Backbone.View.extend({ template: ' \ -
    \ +
    \
    \ \
    \ @@ -1377,6 +1700,12 @@ my.DataExplorer = Backbone.View.extend({
    \ Results found {{docCount}} \
    \ + \ +
    \ +
    \
    \
    \ \ @@ -1387,6 +1716,9 @@ my.DataExplorer = Backbone.View.extend({
    \
    \ ', + events: { + 'click .menu-right a': 'onMenuClick' + }, initialize: function(options) { var self = this; @@ -1479,11 +1811,17 @@ my.DataExplorer = Backbone.View.extend({ var queryEditor = new my.QueryEditor({ model: this.model.queryState }); - this.el.find('.header').append(queryEditor.el); - var queryFacetEditor = new my.FacetViewer({ + this.el.find('.query-editor-here').append(queryEditor.el); + var filterEditor = new my.FilterEditor({ + model: this.model.queryState + }); + this.$filterEditor = filterEditor.el; + this.el.find('.header').append(filterEditor.el); + var facetViewer = new my.FacetViewer({ model: this.model }); - this.el.find('.header').append(queryFacetEditor.el); + this.$facetViewer = facetViewer.el; + this.el.find('.header').append(facetViewer.el); }, setupRouting: function() { @@ -1513,10 +1851,19 @@ my.DataExplorer = Backbone.View.extend({ view.view.el.hide(); } }); + }, + + onMenuClick: function(e) { + e.preventDefault(); + var action = $(e.target).attr('data-action'); + if (action === 'filters') { + this.$filterEditor.show(); + } else if (action === 'facets') { + this.$facetViewer.show(); + } } }); - my.QueryEditor = Backbone.View.extend({ className: 'recline-query-editor', template: ' \ @@ -1524,28 +1871,21 @@ my.QueryEditor = Backbone.View.extend({
    \ \ \ - \
    \ \ + \ \ ', events: { 'submit form': 'onFormSubmit' , 'click .action-pagination-update': 'onPaginationUpdate' - , 'click .menu li a': 'onMenuItemClick' }, initialize: function() { @@ -1557,7 +1897,9 @@ my.QueryEditor = Backbone.View.extend({ onFormSubmit: function(e) { e.preventDefault(); var query = this.el.find('.text-query input').val(); - this.model.set({q: query}); + var newFrom = parseInt(this.el.find('input[name="from"]').val()); + var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom; + this.model.set({size: newSize, from: newFrom, q: query}); }, onPaginationUpdate: function(e) { e.preventDefault(); @@ -1569,20 +1911,6 @@ my.QueryEditor = Backbone.View.extend({ } this.model.set({from: newFrom}); }, - onMenuItemClick: function(e) { - e.preventDefault(); - var attrName = $(e.target).attr('data-action'); - var msg = _.template('New value (<%= value %>)', - {value: this.model.get(attrName)} - ); - var newValue = prompt(msg); - if (newValue) { - newValue = parseInt(newValue); - var update = {}; - update[attrName] = newValue; - this.model.set(update); - } - }, render: function() { var tmplData = this.model.toJSON(); tmplData.to = this.model.get('from') + this.model.get('size'); @@ -1591,6 +1919,105 @@ my.QueryEditor = Backbone.View.extend({ } }); +my.FilterEditor = Backbone.View.extend({ + className: 'recline-filter-editor well', + template: ' \ + × \ +
    \ +
    \ +

    Filters

    \ +
    \ +
    \ +
    \ +
    \ +
    \ + {{#termFilters}} \ +
    \ + \ +
    \ +
    \ + \ + \ +
    \ +
    \ +
    \ + {{/termFilters}} \ +
    \ +
    \ +

    To add a filter use the column menu in the grid view.

    \ + \ +
    \ + \ +
    \ +
    \ + ', + events: { + 'click .js-hide': 'onHide', + 'click .js-remove-filter': 'onRemoveFilter', + 'submit form': 'onTermFiltersUpdate' + }, + initialize: function() { + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.bind('change', this.render); + this.model.bind('change:filters:new-blank', this.render); + this.render(); + }, + render: function() { + var tmplData = $.extend(true, {}, this.model.toJSON()); + // we will use idx in list as there id ... + tmplData.filters = _.map(tmplData.filters, function(filter, idx) { + filter.id = idx; + return filter; + }); + tmplData.termFilters = _.filter(tmplData.filters, function(filter) { + return filter.term !== undefined; + }); + tmplData.termFilters = _.map(tmplData.termFilters, function(filter) { + var fieldId = _.keys(filter.term)[0]; + return { + id: filter.id, + fieldId: fieldId, + label: fieldId, + value: filter.term[fieldId] + } + }); + var out = $.mustache(this.template, tmplData); + this.el.html(out); + // are there actually any facets to show? + if (this.model.get('filters').length > 0) { + this.el.show(); + } else { + this.el.hide(); + } + }, + onHide: function(e) { + e.preventDefault(); + this.el.hide(); + }, + onRemoveFilter: function(e) { + e.preventDefault(); + var $target = $(e.target); + var filterId = $target.closest('.filter').attr('data-filter-id'); + this.model.removeFilter(filterId); + }, + onTermFiltersUpdate: function(e) { + var self = this; + e.preventDefault(); + var filters = self.model.get('filters'); + var $form = $(e.target); + _.each($form.find('input'), function(input) { + var $input = $(input); + var filterIndex = parseInt($input.attr('data-filter-id')); + var value = $input.val(); + var fieldId = $input.attr('data-filter-field'); + filters[filterIndex].term[fieldId] = value; + }); + self.model.set({filters: filters}); + self.model.trigger('change'); + } +}); + my.FacetViewer = Backbone.View.extend({ className: 'recline-facet-viewer well', template: ' \ @@ -1604,7 +2031,7 @@ my.FacetViewer = Backbone.View.extend({ {{id}} {{label}} \ \
    \ @@ -1614,7 +2041,7 @@ my.FacetViewer = Backbone.View.extend({ events: { 'click .js-hide': 'onHide', - 'change .js-facet-filter': 'onFacetFilter' + 'click .js-facet-filter': 'onFacetFilter' }, initialize: function(model) { _.bindAll(this, 'render'); @@ -1642,10 +2069,9 @@ my.FacetViewer = Backbone.View.extend({ this.el.hide(); }, onFacetFilter: function(e) { - // todo: uncheck - var $checkbox = $(e.target); - var fieldId = $checkbox.closest('.facet-summary').attr('data-facet'); - var value = $checkbox.val(); + var $target= $(e.target); + var fieldId = $target.closest('.facet-summary').attr('data-facet'); + var value = $target.attr('data-value'); this.model.queryState.addTermFilter(fieldId, value); } }); @@ -1742,7 +2168,7 @@ my.notify = function(message, options) { {{/loader}} \ '; var _templated = $.mustache(_template, tmplData); - _templated = $(_templated).appendTo($('.data-explorer .alert-messages')); + _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); if (!options.persist) { setTimeout(function() { $(_templated).fadeOut(1000, function() { @@ -1756,7 +2182,7 @@ my.notify = function(message, options) { // // Clear all existing notifications my.clearNotifications = function() { - var $notifications = $('.data-explorer .alert-messages .alert'); + var $notifications = $('.recline-data-explorer .alert-messages .alert'); $notifications.remove(); } diff --git a/src/backend/base.js b/src/backend/base.js index 37d81d33..ec4d8412 100644 --- a/src/backend/base.js +++ b/src/backend/base.js @@ -12,7 +12,7 @@ this.recline.Backend = this.recline.Backend || {}; // Override Backbone.sync to hand off to sync function in relevant backend Backbone.sync = function(method, model, options) { return model.backend.sync(method, model, options); - } + }; // ## recline.Backend.Base // diff --git a/src/backend/dataproxy.js b/src/backend/dataproxy.js index cfbfa342..794b8e79 100644 --- a/src/backend/dataproxy.js +++ b/src/backend/dataproxy.js @@ -38,14 +38,14 @@ this.recline.Backend = this.recline.Backend || {}; var self = this; var base = this.get('dataproxy_url'); var data = { - url: dataset.get('url') - , 'max-results': queryObj.size - , type: dataset.get('format') + url: dataset.get('url'), + 'max-results': queryObj.size, + type: dataset.get('format') }; var jqxhr = $.ajax({ - url: base - , data: data - , dataType: 'jsonp' + url: base, + data: data, + dataType: 'jsonp' }); var dfd = $.Deferred(); this._wrapInTimeout(jqxhr).done(function(results) { diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js index ed571fea..6944a2f4 100644 --- a/src/backend/elasticsearch.js +++ b/src/backend/elasticsearch.js @@ -59,37 +59,33 @@ this.recline.Backend = this.recline.Backend || {}; } }, _normalizeQuery: function(queryObj) { - if (queryObj.toJSON) { - var out = queryObj.toJSON(); - } else { - var out = _.extend({}, queryObj); - } - if (out.q != undefined && out.q.trim() === '') { + var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj); + if (out.q !== undefined && out.q.trim() === '') { delete out.q; } if (!out.q) { out.query = { match_all: {} - } + }; } else { out.query = { query_string: { query: out.q } - } + }; delete out.q; } // now do filters (note the *plural*) if (out.filters && out.filters.length) { if (!out.filter) { - out.filter = {} + out.filter = {}; } if (!out.filter.and) { out.filter.and = []; } out.filter.and = out.filter.and.concat(out.filters); } - if (out.filters != undefined) { + if (out.filters !== undefined) { delete out.filters; } return out; @@ -107,10 +103,10 @@ this.recline.Backend = this.recline.Backend || {}; // TODO: fail case jqxhr.done(function(results) { _.each(results.hits.hits, function(hit) { - if (!'id' in hit._source && hit._id) { + if (!('id' in hit._source) && hit._id) { hit._source.id = hit._id; } - }) + }); if (results.facets) { results.hits.facets = results.facets; } diff --git a/src/backend/gdocs.js b/src/backend/gdocs.js index 3a7a6aaf..8cf0407c 100644 --- a/src/backend/gdocs.js +++ b/src/backend/gdocs.js @@ -23,12 +23,12 @@ this.recline.Backend = this.recline.Backend || {}; return url; } else { // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0 - var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/ + var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/; var matches = url.match(regex); if (matches) { var key = matches[1]; var worksheet = 1; - var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json' + var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json'; return out; } else { alert('Failed to extract gdocs key from ' + url); @@ -52,8 +52,9 @@ this.recline.Backend = this.recline.Backend || {}; // cache data onto dataset (we have loaded whole gdoc it seems!) model._dataCache = result.data; dfd.resolve(model); - }) - return dfd.promise(); } + }); + return dfd.promise(); + } }, query: function(dataset, queryObj) { @@ -64,7 +65,9 @@ this.recline.Backend = this.recline.Backend || {}; // TODO: factor this out as a common method with other backends var objs = _.map(dataset._dataCache, function (d) { var obj = {}; - _.each(_.zip(fields, d), function (x) { obj[x[0]] = x[1]; }) + _.each(_.zip(fields, d), function (x) { + obj[x[0]] = x[1]; + }); return obj; }); dfd.resolve(this._docsToQueryResult(objs)); @@ -101,8 +104,8 @@ this.recline.Backend = this.recline.Backend || {}; if (gdocsSpreadsheet.feed.entry.length > 0) { for (var k in gdocsSpreadsheet.feed.entry[0]) { if (k.substr(0, 3) == 'gsx') { - var col = k.substr(4) - results.field.push(col); + var col = k.substr(4); + results.field.push(col); } } } diff --git a/src/backend/localcsv.js b/src/backend/localcsv.js index a924396c..df96f82f 100644 --- a/src/backend/localcsv.js +++ b/src/backend/localcsv.js @@ -15,7 +15,7 @@ this.recline.Backend = this.recline.Backend || {}; }; reader.onerror = function (e) { alert('Failed to load file. Code: ' + e.target.error.code); - } + }; reader.readAsText(file); }; @@ -33,7 +33,7 @@ this.recline.Backend = this.recline.Backend || {}; }); var dataset = recline.Backend.createDataset(data, fields); return dataset; - } + }; // Converts a Comma Separated Values string into an array of arrays. // Each line in the CSV becomes an array. diff --git a/src/backend/memory.js b/src/backend/memory.js index 1c98be13..6ae7ae13 100644 --- a/src/backend/memory.js +++ b/src/backend/memory.js @@ -15,7 +15,7 @@ this.recline.Backend = this.recline.Backend || {}; // If not defined (or id not provided) id will be autogenerated. my.createDataset = function(data, fields, metadata) { if (!metadata) { - var metadata = {}; + metadata = {}; } if (!metadata.id) { metadata.id = String(Math.floor(Math.random() * 100000000) + 1); @@ -78,8 +78,8 @@ this.recline.Backend = this.recline.Backend || {}; }, sync: function(method, model, options) { var self = this; + var dfd = $.Deferred(); if (method === "read") { - var dfd = $.Deferred(); if (model.__type__ == 'Dataset') { var rawDataset = this.datasets[model.id]; model.set(rawDataset.metadata); @@ -89,7 +89,6 @@ this.recline.Backend = this.recline.Backend || {}; } return dfd.promise(); } else if (method === 'update') { - var dfd = $.Deferred(); if (model.__type__ == 'Document') { _.each(self.datasets[model.dataset.id].documents, function(doc, idx) { if(doc.id === model.id) { @@ -100,7 +99,6 @@ this.recline.Backend = this.recline.Backend || {}; } return dfd.promise(); } else if (method === 'delete') { - var dfd = $.Deferred(); if (model.__type__ == 'Document') { var rawDataset = self.datasets[model.dataset.id]; var newdocs = _.reject(rawDataset.documents, function(doc) { diff --git a/src/model.js b/src/model.js index 7df18923..3253ec7d 100644 --- a/src/model.js +++ b/src/model.js @@ -106,7 +106,25 @@ my.Dataset = Backbone.Model.extend({ // // A single entry or row in the dataset my.Document = Backbone.Model.extend({ - __type__: 'Document' + __type__: 'Document', + initialize: function() { + _.bindAll(this, 'getFieldValue'); + }, + + // ### getFieldValue + // + // For the provided Field get the corresponding rendered computed data value + // for this document. + getFieldValue: function(field) { + var val = this.get(field.id); + if (field.deriver) { + val = field.deriver(val, field, this); + } + if (field.renderer) { + val = field.renderer(val, field, this); + } + return val; + } }); // ## A Backbone collection of Documents @@ -117,25 +135,71 @@ my.DocumentList = Backbone.Collection.extend({ // ## A Field (aka Column) on a Dataset // -// Following attributes as standard: +// Following (Backbone) attributes as standard: // -// * id: a unique identifer for this field- usually this should match the key in the documents hash -// * label: the visible label used for this field -// * type: the type of the data +// * id: a unique identifer for this field- usually this should match the key in the documents hash +// * label: (optional: defaults to id) the visible label used for this field +// * type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on +// * format: (optional) used to indicate how the data should be formatted. For example: +// * type=date, format=yyyy-mm-dd +// * type=float, format=percentage +// * type=float, format='###,###.##' +// * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below). +// +// Following additional instance properties: +// +// @property {Function} renderer: a function to render the data for this field. +// Signature: function(value, field, doc) where value is the value of this +// cell, field is corresponding field object and document is the document +// object. Note that implementing functions can ignore arguments (e.g. +// function(value) would be a valid formatter function). +// +// @property {Function} deriver: a function to derive/compute the value of data +// in this field as a function of this field's value (if any) and the current +// document, its signature and behaviour is the same as for renderer. Use of +// this function allows you to define an entirely new value for data in this +// field. This provides support for a) 'derived/computed' fields: i.e. fields +// whose data are functions of the data in other fields b) transforming the +// value of this field prior to rendering. my.Field = Backbone.Model.extend({ + // ### defaults - define default values defaults: { - id: null, label: null, - type: 'String' + type: 'string', + format: null, + is_derived: false }, - initialize: function(data) { + // ### initialize + // + // @param {Object} data: standard Backbone model attributes + // + // @param {Object} options: renderer and/or deriver functions. + initialize: function(data, options) { // if a hash not passed in the first argument throw error if ('0' in data) { throw new Error('Looks like you did not pass a proper hash with id to Field constructor'); } - if (this.attributes.label == null) { + if (this.attributes.label === null) { this.set({label: this.id}); } + if (options) { + this.renderer = options.renderer; + this.deriver = options.deriver; + } + if (!this.renderer) { + this.renderer = this.defaultRenderers[this.get('type')]; + } + }, + defaultRenderers: { + object: function(val, field, doc) { + return JSON.stringify(val); + }, + 'float': function(val, field, doc) { + var format = field.get('format'); + if (format === 'percentage') { + return val + '%'; + } + } } }); @@ -167,7 +231,7 @@ my.FieldList = Backbone.Collection.extend({ // * query: Query in ES Query DSL // * filter: See filters and Filtered Query // * fields: set of fields to return - http://www.elasticsearch.org/guide/reference/api/search/fields.html -// * facets: TODO - see http://www.elasticsearch.org/guide/reference/api/search/facets/ +// * facets: specification of facets - see http://www.elasticsearch.org/guide/reference/api/search/facets/ // // Additions: // @@ -195,13 +259,13 @@ my.FieldList = Backbone.Collection.extend({ my.Query = Backbone.Model.extend({ defaults: function() { return { - size: 100 - , from: 0 - , facets: {} + size: 100, + from: 0, + facets: {}, // // , filter: {} - , filters: [] - } + filters: [] + }; }, // #### addTermFilter // @@ -247,6 +311,17 @@ my.Query = Backbone.Model.extend({ }; this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); + }, + addHistogramFacet: function(fieldId) { + var facets = this.get('facets'); + facets[fieldId] = { + date_histogram: { + field: fieldId, + interval: 'day' + } + }; + this.set({facets: facets}, {silent: true}); + this.trigger('facet:add', this); } }); @@ -297,7 +372,7 @@ my.Facet = Backbone.Model.extend({ other: 0, missing: 0, terms: [] - } + }; } }); diff --git a/src/util.js b/src/util.js index ab2acbef..cd0086be 100644 --- a/src/util.js +++ b/src/util.js @@ -2,8 +2,8 @@ var util = function() { var templates = { - transformActions: '
  • Global transform...
  • ' - , cellEditor: ' \ + transformActions: '
  • Global transform...
  • ', + cellEditor: ' \ \ \ - ' - , editPreview: ' \ + ', + editPreview: ' \
    \
    \ \ @@ -63,7 +63,7 @@ var util = function() { function registerEmitter() { var Emitter = function(obj) { this.emit = function(obj, channel) { - if (!channel) var channel = 'data'; + if (!channel) channel = 'data'; this.trigger(channel, obj); }; }; @@ -80,7 +80,7 @@ var util = function() { 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/", 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8", 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 191: "/", 224: "meta" - } + }; window.addEventListener("keyup", function(e) { var pressed = shortcuts[e.keyCode]; if(_.include(keys, pressed)) app.emitter.emit("keyup", pressed); @@ -126,10 +126,11 @@ var util = function() { if ( !options ) options = {data: {}}; if ( !options.data ) options = {data: options}; var html = $.mustache( templates[template], options.data ); + var targetDom = null; if (target instanceof jQuery) { - var targetDom = target; + targetDom = target; } else { - var targetDom = $( "." + target + ":first" ); + targetDom = $( "." + target + ":first" ); } if( options.append ) { targetDom.append( html ); diff --git a/src/view-flot-graph.js b/src/view-flot-graph.js index cb47f441..afb8f061 100644 --- a/src/view-flot-graph.js +++ b/src/view-flot-graph.js @@ -80,10 +80,10 @@ my.FlotGraph = Backbone.View.extend({ ', events: { - 'change form select': 'onEditorSubmit' - , 'click .editor-add': 'addSeries' - , 'click .action-remove-series': 'removeSeries' - , 'click .action-toggle-help': 'toggleHelp' + 'change form select': 'onEditorSubmit', + 'click .editor-add': 'addSeries', + 'click .action-remove-series': 'removeSeries', + 'click .action-toggle-help': 'toggleHelp' }, initialize: function(options, config) { @@ -129,12 +129,12 @@ my.FlotGraph = Backbone.View.extend({ var series = this.$series.map(function () { return $(this).val(); }); - this.chartConfig.series = $.makeArray(series) + this.chartConfig.series = $.makeArray(series); this.chartConfig.group = this.el.find('.editor-group select').val(); this.chartConfig.graphType = this.el.find('.editor-type select').val(); // update navigation var qs = my.parseHashQueryString(); - qs['graph'] = JSON.stringify(this.chartConfig); + qs.graph = JSON.stringify(this.chartConfig); my.setHashQueryString(qs); this.redraw(); }, @@ -147,8 +147,8 @@ my.FlotGraph = Backbone.View.extend({ // Uncaught Invalid dimensions for plot, width = 0, height = 0 // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); - if ((!areWeVisible || this.model.currentDocuments.length == 0)) { - return + if ((!areWeVisible || this.model.currentDocuments.length === 0)) { + return; } var series = this.createSeries(); var options = this.getGraphOptions(this.chartConfig.graphType); @@ -181,7 +181,7 @@ my.FlotGraph = Backbone.View.extend({ } } return val; - } + }; // TODO: we should really use tickFormatter and 1 interval ticks if (and // only if) x-axis values are non-numeric // However, that is non-trivial to work out from a dataset (datasets may @@ -191,21 +191,21 @@ my.FlotGraph = Backbone.View.extend({ series: { lines: { show: true } } - } - , points: { + }, + points: { series: { points: { show: true } }, grid: { hoverable: true, clickable: true } - } - , 'lines-and-points': { + }, + 'lines-and-points': { series: { points: { show: true }, lines: { show: true } }, grid: { hoverable: true, clickable: true } - } - , bars: { + }, + bars: { series: { lines: {show: false}, bars: { @@ -225,7 +225,7 @@ my.FlotGraph = Backbone.View.extend({ max: self.model.currentDocuments.length - 0.5 } } - } + }; return options[typeId]; }, diff --git a/src/view-grid.js b/src/view-grid.js index ac539f67..eb736524 100644 --- a/src/view-grid.js +++ b/src/view-grid.js @@ -8,16 +8,12 @@ this.recline.View = this.recline.View || {}; // // Provides a tabular view on a Dataset. // -// Initialize it with a recline.Dataset object. -// -// Additional options passed in second arguments. Options: -// -// * cellRenderer: function used to render individual cells. See DataGridRow for more. +// Initialize it with a `recline.Model.Dataset`. my.DataGrid = Backbone.View.extend({ tagName: "div", - className: "data-table-container", + className: "recline-grid-container", - initialize: function(modelEtc, options) { + initialize: function(modelEtc) { var self = this; this.el = $(this.el); _.bindAll(this, 'render'); @@ -26,14 +22,13 @@ my.DataGrid = Backbone.View.extend({ this.model.currentDocuments.bind('remove', this.render); this.state = {}; this.hiddenFields = []; - this.options = options; }, events: { - 'click .column-header-menu': 'onColumnHeaderClick' - , 'click .row-header-menu': 'onRowHeaderClick' - , 'click .root-header-menu': 'onRootHeaderClick' - , 'click .data-table-menu li a': 'onMenuClick' + 'click .column-header-menu': 'onColumnHeaderClick', + 'click .row-header-menu': 'onRowHeaderClick', + 'click .root-header-menu': 'onRootHeaderClick', + 'click .data-table-menu li a': 'onMenuClick' }, // TODO: delete or re-enable (currently this code is not used from anywhere except deprecated or disabled methods (see above)). @@ -72,33 +67,35 @@ my.DataGrid = Backbone.View.extend({ var self = this; e.preventDefault(); var actions = { - bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}); }, facet: function() { self.model.queryState.addFacet(self.state.currentColumn); }, + facet_histogram: function() { + self.model.queryState.addHistogramFacet(self.state.currentColumn); + }, filter: function() { self.model.queryState.addTermFilter(self.state.currentColumn, ''); }, - transform: function() { self.showTransformDialog('transform') }, - sortAsc: function() { self.setColumnSort('asc') }, - sortDesc: function() { self.setColumnSort('desc') }, - hideColumn: function() { self.hideColumn() }, - showColumn: function() { self.showColumn(e) }, + transform: function() { self.showTransformDialog('transform'); }, + sortAsc: function() { self.setColumnSort('asc'); }, + sortDesc: function() { self.setColumnSort('desc'); }, + hideColumn: function() { self.hideColumn(); }, + showColumn: function() { self.showColumn(e); }, deleteRow: function() { var doc = _.find(self.model.currentDocuments.models, function(doc) { // important this is == as the currentRow will be string (as comes // from DOM) while id may be int - return doc.id == self.state.currentRow + return doc.id == self.state.currentRow; }); doc.destroy().then(function() { self.model.currentDocuments.remove(doc); my.notify("Row deleted successfully"); - }) - .fail(function(err) { - my.notify("Errorz! " + err) - }) + }).fail(function(err) { + my.notify("Errorz! " + err); + }); } - } + }; actions[$(e.target).attr('data-action')](); }, @@ -114,7 +111,7 @@ my.DataGrid = Backbone.View.extend({ $el.append(view.el); util.observeExit($el, function() { util.hide('dialog'); - }) + }); $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); }, @@ -128,7 +125,7 @@ my.DataGrid = Backbone.View.extend({ $el.append(view.el); util.observeExit($el, function() { util.hide('dialog'); - }) + }); $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); }, @@ -151,7 +148,7 @@ my.DataGrid = Backbone.View.extend({ // ====================================================== // #### Templating template: ' \ -
    \ +
    \ \ \ {{#notEmpty}} \ @@ -169,7 +166,8 @@ my.DataGrid = Backbone.View.extend({
    \ \
    \ {{/termFilters}} \ \ -
    \ +
    \ +

    To add a filter use the column menu in the grid view.

    \ \
    \ \ @@ -347,7 +349,7 @@ my.FilterEditor = Backbone.View.extend({ fieldId: fieldId, label: fieldId, value: filter.term[fieldId] - } + }; }); var out = $.mustache(this.template, tmplData); this.el.html(out); @@ -398,8 +400,11 @@ my.FacetViewer = Backbone.View.extend({ {{id}} {{label}} \ \
    \ {{/facets}} \ @@ -408,7 +413,7 @@ my.FacetViewer = Backbone.View.extend({ events: { 'click .js-hide': 'onHide', - 'change .js-facet-filter': 'onFacetFilter' + 'click .js-facet-filter': 'onFacetFilter' }, initialize: function(model) { _.bindAll(this, 'render'); @@ -422,6 +427,15 @@ my.FacetViewer = Backbone.View.extend({ facets: this.model.facets.toJSON(), fields: this.model.fields.toJSON() }; + tmplData.facets = _.map(tmplData.facets, function(facet) { + if (facet._type === 'date_histogram') { + facet.entries = _.map(facet.entries, function(entry) { + entry.term = new Date(entry.time).toDateString(); + return entry; + }); + } + return facet; + }); var templated = $.mustache(this.template, tmplData); this.el.html(templated); // are there actually any facets to show? @@ -436,10 +450,9 @@ my.FacetViewer = Backbone.View.extend({ this.el.hide(); }, onFacetFilter: function(e) { - // todo: uncheck - var $checkbox = $(e.target); - var fieldId = $checkbox.closest('.facet-summary').attr('data-facet'); - var value = $checkbox.val(); + var $target= $(e.target); + var fieldId = $target.closest('.facet-summary').attr('data-facet'); + var value = $target.attr('data-value'); this.model.queryState.addTermFilter(fieldId, value); } }); @@ -452,15 +465,15 @@ var urlPathRegex = /^([^?]+)(\?.*)?/; // Parse the Hash section of a URL into path and query string my.parseHashUrl = function(hashUrl) { var parsed = urlPathRegex.exec(hashUrl); - if (parsed == null) { + if (parsed === null) { return {}; } else { return { path: parsed[1], query: parsed[2] || '' - } + }; } -} +}; // Parse a URL query string (?xyz=abc...) into a dictionary. my.parseQueryString = function(q) { @@ -481,13 +494,13 @@ my.parseQueryString = function(q) { urlParams[d(e[1])] = d(e[2]); } return urlParams; -} +}; // Parse the query string out of the URL hash my.parseHashQueryString = function() { q = my.parseHashUrl(window.location.hash).query; return my.parseQueryString(q); -} +}; // Compse a Query String my.composeQueryString = function(queryParams) { @@ -498,7 +511,7 @@ my.composeQueryString = function(queryParams) { }); queryString += items.join('&'); return queryString; -} +}; my.getNewHashForQueryString = function(queryParams) { var queryPart = my.composeQueryString(queryParams); @@ -508,11 +521,11 @@ my.getNewHashForQueryString = function(queryParams) { } else { return queryPart; } -} +}; my.setHashQueryString = function(queryParams) { window.location.hash = my.getNewHashForQueryString(queryParams); -} +}; // ## notify // @@ -522,7 +535,7 @@ my.setHashQueryString = function(queryParams) { // * persist: if true alert is persistent, o/w hidden after 3s (default = false) // * loader: if true show loading spinner my.notify = function(message, options) { - if (!options) var options = {}; + if (!options) options = {}; var tmplData = _.extend({ msg: message, category: 'warning' @@ -536,7 +549,7 @@ my.notify = function(message, options) { {{/loader}} \ '; var _templated = $.mustache(_template, tmplData); - _templated = $(_templated).appendTo($('.data-explorer .alert-messages')); + _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); if (!options.persist) { setTimeout(function() { $(_templated).fadeOut(1000, function() { @@ -544,15 +557,15 @@ my.notify = function(message, options) { }); }, 1000); } -} +}; // ## clearNotifications // // Clear all existing notifications my.clearNotifications = function() { - var $notifications = $('.data-explorer .alert-messages .alert'); + var $notifications = $('.recline-data-explorer .alert-messages .alert'); $notifications.remove(); -} +}; })(jQuery, recline.View); diff --git a/test/model.test.js b/test/model.test.js index 1bdff485..2625a7a4 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -38,6 +38,50 @@ test('Field: basics', function () { equal('XX', out[0].label); }); +test('Field: default renderers', function () { + var doc = new recline.Model.Document({x: 12.3, myobject: {a: 1, b: 2}}); + var field = new recline.Model.Field({id: 'myobject', type: 'object'}); + var out = doc.getFieldValue(field); + var exp = '{"a":1,"b":2}'; + equal(out, exp); + + var field = new recline.Model.Field({id: 'x', type: 'float', format: 'percentage'}); + var out = doc.getFieldValue(field); + var exp = '12.3%'; + equal(out, exp); +}); + +test('Field: custom deriver and renderer', function () { + var doc = new recline.Model.Document({x: 123}); + var cellRenderer = function(value, field) { + return '' + value + ''; + } + var deriver = function(val, field, doc) { + return doc.get('x') * 2 + } + + var field = new recline.Model.Field({id: 'computed', is_derived: true}, { + deriver: deriver + }); + var out = doc.getFieldValue(field); + var exp = 246; + equal(out, exp); + + var field = new recline.Model.Field({id: 'x'}, { + renderer: cellRenderer + }); + var out = doc.getFieldValue(field); + var exp = '123' + equal(out, exp); + + var field = new recline.Model.Field({id: 'computed'}, { + renderer: cellRenderer, + deriver: deriver + }); + var out = doc.getFieldValue(field); + var exp = '246' + equal(out, exp); +}); // ================================= // Dataset diff --git a/test/view.test.js b/test/view.test.js index 05adb60e..4ffa5d02 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -20,20 +20,6 @@ test('new DataGridRow View', function () { var tds = $el.find('td'); equal(tds.length, 3); equal($(tds[1]).attr('data-field'), 'a'); - - var view = new recline.View.DataGridRow({ - model: doc - , el: $el - , fields: new recline.Model.FieldList([{id: 'a'}, {id: 'b'}]) - }, - { - cellRenderer: function(value, field) { - return '' + value + ''; - } - }); - view.render(); - var tds = $el.find('td .data-table-cell-value'); - equal($(tds[0]).html(), '1', 'Checking cellRenderer works'); }); })(this.jQuery);