From 8b746f707c02427e41cb6113007f0f567fae1638 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 07:17:30 +0100 Subject: [PATCH 01/13] [css/grid.css][xs]: remove dialog css as made obsolete when we moved to boostrap modal - cf 9c0c4dfae3618c999e2db5d3f8b600fbd47c2a41. --- css/grid.css | 65 ---------------------------------------------------- 1 file changed, 65 deletions(-) diff --git a/css/grid.css b/css/grid.css index 88f0b134..f9c2c80e 100644 --- a/css/grid.css +++ b/css/grid.css @@ -141,71 +141,6 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit { } -/********************************************************** - * Dialogs - *********************************************************/ - -.dialog-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: #666; - opacity: 0.5; -} - -.dialog { - position: fixed; - left: 0; - width: 100%; - text-align: center; -} - -.dialog-frame { - margin: 0 auto; - text-align: left; - background: white; - border: 1px solid #3a5774; -} - -.dialog-border { - border: 4px solid #c1d9ff; -} - -.dialog-header { - background: #e0edfe; - padding: 10px; - font-weight: bold; - font-size: 1.6em; - color: #000; - cursor: move; -} - -.dialog-body { - overflow: auto; - font-size: 1.3em; - padding: 15px; -} - -.dialog-instruction { - padding: 0 0 7px; -} - -.dialog-footer { - font-size: 1.3em; - background: #eee; - padding: 10px; -} - -.dialog-busy { - width: 400px; - border: none; - -moz-border-radius: 5px; - -webkit-border-radius: 5px; - border-radius: 5px; -} - /********************************************************** * Transform Dialog *********************************************************/ From 44942dabdc6e2f73178e27e3bfe3083cb0da5581 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 07:57:55 +0100 Subject: [PATCH 02/13] [backend/memory][xs]: createDataset calls query on dataset object so that the Dataset object already has data in it. --- src/backend/memory.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/memory.js b/src/backend/memory.js index e013aa19..4783c20d 100644 --- a/src/backend/memory.js +++ b/src/backend/memory.js @@ -37,6 +37,7 @@ this.recline.Backend = this.recline.Backend || {}; backend.addDataset(datasetInfo); var dataset = new recline.Model.Dataset({id: metadata.id}, backend); dataset.fetch(); + dataset.query(); return dataset; }; From f229d6985c5b924f3fd333ab160f7e3005dd399f Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 08:25:23 +0100 Subject: [PATCH 03/13] [view/grid][xs]: fix to grid css so drop-down menus are not hidden if they overflow plus move no-hidden attribute onto recline-grid making css simpler and more reliable. --- css/grid.css | 9 ++------- src/view-grid.js | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/css/grid.css b/css/grid.css index f9c2c80e..c0439459 100644 --- a/css/grid.css +++ b/css/grid.css @@ -7,11 +7,6 @@ line-height: auto; } -.recline-grid-container { - overflow: auto; - height: 550px; -} - .recline-grid { border: 1px solid #ccc; width: 100%; @@ -232,8 +227,8 @@ td.expression-preview-value { * Read-only mode *********************************************************/ -.recline-read-only .no-hidden .recline-grid tr td:first-child, -.recline-read-only .no-hidden .recline-grid tr th:first-child +.recline-read-only .recline-grid.no-hidden tr td:first-child, +.recline-read-only .recline-grid.no-hidden tr th:first-child { display: none; } diff --git a/src/view-grid.js b/src/view-grid.js index 30e81a33..fc158723 100644 --- a/src/view-grid.js +++ b/src/view-grid.js @@ -193,7 +193,7 @@ my.Grid = Backbone.View.extend({ }); newView.render(); }); - this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); + this.el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); return this; } }); From accd96354e37159349cd1584836a261caf0121ff Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 08:32:53 +0100 Subject: [PATCH 04/13] [view,doc][xs]: tweak view overview info. --- src/view.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/view.js b/src/view.js index ac4a2f28..ed3edfce 100644 --- a/src/view.js +++ b/src/view.js @@ -2,10 +2,11 @@ // # Recline Views // -// Recline Views are Backbone Views and in keeping with normal Backbone views -// are Widgets / Components displaying something in the DOM. Like all Backbone -// views they have a pointer to a model or a collection and is bound to an -// element. +// Recline Views are instances of Backbone Views and they act as 'WUI' (web +// user interface) component displaying some model object in the DOM. Like all +// Backbone views they have a pointer to a model (or a collection) and have an +// associated DOM-style element (usually this element will be bound into the +// page at some point). // // Views provided by core Recline are crudely divided into two types: // From d0053070d2b6a66869ff699b9810494398de607a Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 08:33:42 +0100 Subject: [PATCH 05/13] [build][m]: regular build of recline.js and docs (have not done for 2+ weeks). --- docs/backend/localcsv.html | 10 +- docs/backend/memory.html | 1 + docs/view-graph.html | 206 +++++++---- docs/view-grid.html | 83 ++--- docs/view-map.html | 102 ++++-- docs/view.html | 203 ++++------- recline.js | 688 ++++++++++++++----------------------- 7 files changed, 575 insertions(+), 718 deletions(-) diff --git a/docs/backend/localcsv.html b/docs/backend/localcsv.html index 71f105bf..3c708f28 100644 --- a/docs/backend/localcsv.html +++ b/docs/backend/localcsv.html @@ -51,7 +51,9 @@ thttp://www.uselesscode.org/javascript/csv/

var options = options || {}; var trm = options.trim; var separator = options.separator || ','; - + var delimiter = options.delimiter || '"'; + + var cur = '', // The character we are currently processing. inQuote = false, fieldQuoted = false, @@ -81,13 +83,13 @@ thttp://www.uselesscode.org/javascript/csv/

row = []; }

Flush the field buffer

        field = '';
         fieldQuoted = false;
-      } else {

If it's not a ", add it to the field buffer

        if (cur !== '"') {
+      } else {

If it's not a delimiter, add it to the field buffer

        if (cur !== delimiter) {
           field += cur;
         } else {
           if (!inQuote) {

We are not in a quote, start a quote

            inQuote = true;
             fieldQuoted = true;
-          } else {

Next char is ", this is an escaped "

            if (s.charAt(i + 1) === '"') {
-              field += '"';

Skip the next char

              i += 1;
+          } else {

Next char is delimiter, this is an escaped delimiter

            if (s.charAt(i + 1) === delimiter) {
+              field += delimiter;

Skip the next char

              i += 1;
             } else {

It's not escaping, so end quote

              inQuote = false;
             }
           }
diff --git a/docs/backend/memory.html b/docs/backend/memory.html
index 6304cee6..e1c865a7 100644
--- a/docs/backend/memory.html
+++ b/docs/backend/memory.html
@@ -35,6 +35,7 @@ If not defined (or id not provided) id will be autogenerated.

backend.addDataset(datasetInfo); var dataset = new recline.Model.Dataset({id: metadata.id}, backend); dataset.fetch(); + dataset.query(); return dataset; };

Memory Backend - uses in-memory data

diff --git a/docs/view-graph.html b/docs/view-graph.html index 7e13d54b..e71795ba 100644 --- a/docs/view-graph.html +++ b/docs/view-graph.html @@ -47,22 +47,13 @@ generate the element itself (you can then append view.el to the DOM.

<label>Group Column (x-axis)</label> \ <div class="input editor-group"> \ <select> \ + <option value="">Please choose ...</option> \ {{#fields}} \ <option value="{{id}}">{{label}}</option> \ {{/fields}} \ </select> \ </div> \ <div class="editor-series-group"> \ - <div class="editor-series"> \ - <label>Series <span>A (y-axis)</span></label> \ - <div class="input"> \ - <select> \ - {{#fields}} \ - <option value="{{id}}">{{label}}</option> \ - {{/fields}} \ - </select> \ - </div> \ - </div> \ </div> \ </div> \ <div class="editor-buttons"> \ @@ -74,13 +65,33 @@ generate the element itself (you can then append view.el to the DOM.

</div> \ </form> \ </div> \ - <div class="panel graph"></div> \ + <div class="panel graph"> \ + <div class="js-temp-notice alert alert-block"> \ + <h3 class="alert-heading">Hey there!</h3> \ + <p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \ + <p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \ + </div> \ + </div> \ </div> \ ', + templateSeriesEditor: ' \ + <div class="editor-series js-series-{{seriesIndex}}"> \ + <label>Series <span>{{seriesName}} (y-axis)</span> \ + [<a href="#remove" class="action-remove-series">Remove</a>] \ + </label> \ + <div class="input"> \ + <select> \ + {{#fields}} \ + <option value="{{id}}">{{label}}</option> \ + {{/fields}} \ + </select> \ + </div> \ + </div> \ + ', events: { 'change form select': 'onEditorSubmit', - 'click .editor-add': 'addSeries', + 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries', 'click .action-toggle-help': 'toggleHelp' }, @@ -88,14 +99,19 @@ generate the element itself (you can then append view.el to the DOM.

initialize: function(options) { var self = this; this.el = $(this.el); - _.bindAll(this, 'render', 'redraw');

we need the model.fields to render properly

    this.model.bind('change', this.render);
+    _.bindAll(this, 'render', 'redraw');
+    this.needToRedraw = false;
+    this.model.bind('change', this.render);
     this.model.fields.bind('reset', this.render);
     this.model.fields.bind('add', this.render);
     this.model.currentDocuments.bind('add', this.redraw);
-    this.model.currentDocuments.bind('reset', this.redraw);
+    this.model.currentDocuments.bind('reset', this.redraw);

because we cannot redraw when hidden we may need when becoming visible

    this.bind('view:show', function() {
+      if (this.needToRedraw) {
+        self.redraw();
+      }
+    });
     var stateData = _.extend({
-        group: null,
-        series: [],
+        group: null,

so that at least one series chooser box shows up

        series: [],
         graphType: 'lines-and-points'
       },
       options.state
@@ -105,17 +121,41 @@ generate the element itself (you can then append view.el to the DOM.

}, render: function() { - htmls = $.mustache(this.template, this.model.toTemplateJSON()); - $(this.el).html(htmls);

now set a load of stuff up

    this.$graph = this.el.find('.panel.graph');

for use later when adding additional series -could be simpler just to have a common template!

    this.$seriesClone = this.el.find('.editor-series').clone();
-    this._updateSeries();
+    var self = this;
+    var tmplData = this.model.toTemplateJSON();
+    var htmls = $.mustache(this.template, tmplData);
+    $(this.el).html(htmls);
+    this.$graph = this.el.find('.panel.graph');

set up editor from state

    if (this.state.get('graphType')) {
+      this._selectOption('.editor-type', this.state.get('graphType'));
+    }
+    if (this.state.get('group')) {
+      this._selectOption('.editor-group', this.state.get('group'));
+    }

ensure at least one series box shows up

    var tmpSeries = [""];
+    if (this.state.get('series').length > 0) {
+      tmpSeries = this.state.get('series');
+    }
+    _.each(tmpSeries, function(series, idx) {
+      self.addSeries(idx);
+      self._selectOption('.editor-series.js-series-' + idx, series);
+    });
     return this;
+  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
+    var options = this.el.find(id + ' select > option');
+    if (options) {
+      options.each(function(opt){
+        if (this.value == value) {
+          $(this).attr('selected','selected');
+          return false;
+        }
+      });
+    }
   },
 
   onEditorSubmit: function(e) {
     var select = this.el.find('.editor-group select');
-    $editor = this;
-    var series = this.$series.map(function () {
+    var $editor = this;
+    var $series  = this.el.find('.editor-series select');
+    var series = $series.map(function () {
       return $(this).val();
     });
     var updatedState = {
@@ -127,50 +167,59 @@ could be simpler just to have a common template!

this.redraw(); }, - redraw: function() {

There appear to be issues generating a Flot graph if either:

    + redraw: function() {

    There appear to be issues generating a Flot graph if either:

    • The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with

      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)) {
    +      this.needToRedraw = true;
           return;
    +    }

    check we have something to plot

        if (this.state.get('group') && this.state.get('series')) {
    +      var series = this.createSeries();
    +      var options = this.getGraphOptions(this.state.attributes.graphType);
    +      this.plot = $.plot(this.$graph, series, options);
    +      this.setupTooltips();
         }
    -    var series = this.createSeries();
    -    var options = this.getGraphOptions(this.state.attributes.graphType);
    -    this.plot = $.plot(this.$graph, series, options);
    -    this.setupTooltips();

    create this.plot and cache it - if (!this.plot) { - this.plot = $.plot(this.$graph, series, options); - } else { - this.plot.parseOptions(options); - this.plot.setData(this.createSeries()); - this.plot.resize(); - this.plot.setupGrid(); - this.plot.draw(); - }

      },

    needs to be function as can depend on state

      getGraphOptions: function(typeId) { 
    -    var self = this;

    special tickformatter to show labels rather than numbers

        var tickFormatter = function (val) {
    +  },

    getGraphOptions

    + +

    Get options for Flot Graph

    + +

    needs to be function as can depend on state

    + +

    @param typeId graphType id (lines, lines-and-points etc)

      getGraphOptions: function(typeId) { 
    +    var self = this;

    special tickformatter to show labels rather than numbers +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 +have no field type info). Thus at present we only do this for bars.

        var tickFormatter = function (val) {
           if (self.model.currentDocuments.models[val]) {
    -        var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);

    if the value was in fact a number we want that not the

            if (typeof(out) == 'number') {
    +        var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);

    if the value was in fact a number we want that not the

            if (typeof(out) == 'number') {
               return val;
             } else {
               return out;
             }
           }
           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 -have no field type info). Thus at present we only do this for bars.

        var options = { 
    +    };
    +
    +    var xaxis = {};

    check for time series on x-axis

        if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
    +      xaxis.mode = 'time';
    +      xaxis.timeformat = '%y-%b';
    +    }
    +    var optionsPerGraphType = { 
           lines: {
    -         series: { 
    -           lines: { show: true }
    -         }
    +        series: { 
    +          lines: { show: true }
    +        },
    +        xaxis: xaxis
           },
           points: {
             series: {
               points: { show: true }
             },
    +        xaxis: xaxis,
             grid: { hoverable: true, clickable: true }
           },
           'lines-and-points': {
    @@ -178,6 +227,7 @@ have no field type info). Thus at present we only do this for bars.

    points: { show: true }, lines: { show: true } }, + xaxis: xaxis, grid: { hoverable: true, clickable: true } }, bars: { @@ -201,7 +251,7 @@ have no field type info). Thus at present we only do this for bars.

    } } }; - return options[typeId]; + return optionsPerGraphType[typeId]; }, setupTooltips: function() { @@ -227,12 +277,20 @@ have no field type info). Thus at present we only do this for bars.

    $("#flot-tooltip").remove(); var x = item.datapoint[0]; - var y = item.datapoint[1];

    convert back from 'index' value on x-axis (e.g. in cases where non-number values)

              if (self.model.currentDocuments.models[x]) {
    +          var y = item.datapoint[1];

    it's horizontal so we have to flip

              if (self.state.attributes.graphType === 'bars') {
    +            var _tmp = x;
    +            x = y;
    +            y = _tmp;
    +          }

    convert back from 'index' value on x-axis (e.g. in cases where non-number values)

              if (self.model.currentDocuments.models[x]) {
                 x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
               } else {
                 x = x.toFixed(2);
               }
    -          y = y.toFixed(2);
    +          y = y.toFixed(2);

    is it time series

              var xfield = self.model.fields.get(self.state.attributes.group);
    +          var isDateTime = xfield.get('type') === 'date';
    +          if (isDateTime) {
    +            x = new Date(parseInt(x)).toLocaleDateString();
    +          }
               
               var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
                 group: self.state.attributes.group,
    @@ -256,11 +314,16 @@ have no field type info). Thus at present we only do this for bars.

    _.each(this.state.attributes.series, function(field) { var points = []; _.each(self.model.currentDocuments.models, function(doc, index) { - var x = doc.get(self.state.attributes.group); - var y = doc.get(field); + var xfield = self.model.fields.get(self.state.attributes.group); + var x = doc.getFieldValue(xfield);

    time series

            var isDateTime = xfield.get('type') === 'date';
    +        if (isDateTime) {
    +          x = new Date(x);
    +        }
    +        var yfield = self.model.fields.get(field);
    +        var y = doc.getFieldValue(yfield);
             if (typeof x === 'string') {
               x = index;
    -        }

    horizontal bar chart

            if (self.state.attributes.graphType == 'bars') {
    +        }

    horizontal bar chart

            if (self.state.attributes.graphType == 'bars') {
               points.push([y, x]);
             } else {
               points.push([x, y]);
    @@ -269,45 +332,36 @@ have no field type info). Thus at present we only do this for bars.

    series.push({data: points, label: field}); }); return series; - },

    Public: Adds a new empty series select box to the editor.

    + },

    Public: Adds a new empty series select box to the editor.

    -

    All but the first select box will have a remove button that allows them -to be removed.

    +

    @param [int] idx index of this series in the list of series

    -

    Returns itself.

      addSeries: function (e) {
    -    e.preventDefault();
    -    var element = this.$seriesClone.clone(),
    -        label   = element.find('label'),
    -        index   = this.$series.length;
    +

    Returns itself.

      addSeries: function (idx) {
    +    var data = _.extend({
    +      seriesIndex: idx,
    +      seriesName: String.fromCharCode(idx + 64 + 1),
    +    }, this.model.toTemplateJSON());
     
    -    this.el.find('.editor-series-group').append(element);
    -    this._updateSeries();
    -    label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
    -    label.find('span').text(String.fromCharCode(this.$series.length + 64));
    +    var htmls = $.mustache(this.templateSeriesEditor, data);
    +    this.el.find('.editor-series-group').append(htmls);
         return this;
    -  },

    Public: Removes a series list item from the editor.

    + }, + + _onAddSeries: function(e) { + e.preventDefault(); + this.addSeries(this.state.get('series').length); + },

    Public: Removes a series list item from the editor.

    Also updates the labels of the remaining series elements.

      removeSeries: function (e) {
         e.preventDefault();
         var $el = $(e.target);
         $el.parent().parent().remove();
    -    this._updateSeries();
    -    this.$series.each(function (index) {
    -      if (index > 0) {
    -        var labelSpan = $(this).prev().find('span');
    -        labelSpan.text(String.fromCharCode(index + 65));
    -      }
    -    });
         this.onEditorSubmit();
       },
     
       toggleHelp: function() {
         this.el.find('.editor-info').toggleClass('editor-hide-info');
    -  },

    Private: Resets the series property to reference the select elements.

    - -

    Returns itself.

      _updateSeries: function () {
    -    this.$series  = this.el.find('.editor-series select');
    -  }
    +  },
     });
     
     })(jQuery, recline.View);
    diff --git a/docs/view-grid.html b/docs/view-grid.html
    index 7999d978..2c57e2cc 100644
    --- a/docs/view-grid.html
    +++ b/docs/view-grid.html
    @@ -31,16 +31,7 @@
         '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)). -showDialog: function(template, data) { - if (!data) data = {}; - util.show('dialog'); - util.render(template, 'dialog-content', data); - util.observeExit($('.dialog-content'), function() { - util.hide('dialog'); - }) - $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); -},

    ====================================================== + },

    ====================================================== Column and row menus

      onColumnHeaderClick: function(e) {
         this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
       },
    @@ -72,20 +63,20 @@ Column and row menus

    filter: function() { self.model.queryState.addTermFilter(self.tempState.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); }, deleteRow: function() { - var doc = _.find(self.model.currentDocuments.models, function(doc) {

    important this is == as the currentRow will be string (as comes + var self = this; + 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.tempState.currentRow;
             });
             doc.destroy().then(function() { 
                 self.model.currentDocuments.remove(doc);
    -            my.notify("Row deleted successfully");
    +            self.trigger('recline:flash', {message: "Row deleted successfully"});
               }).fail(function(err) {
    -            my.notify("Errorz! " + err);
    +            self.trigger('recline:flash', {message: "Errorz! " + err});
               });
           }
         };
    @@ -93,33 +84,16 @@ from DOM) while id may be int

    }, showTransformColumnDialog: function() { - var $el = $('.dialog-content'); - util.show('dialog'); + var self = this; var view = new my.ColumnTransform({ model: this.model + });

    pass the flash message up the chain

        view.bind('recline:flash', function(flash) {
    +      self.trigger('recline:flash', flash);
         });
         view.state = this.tempState;
         view.render();
    -    $el.empty();
    -    $el.append(view.el);
    -    util.observeExit($el, function() {
    -      util.hide('dialog');
    -    });
    -    $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' });
    -  },
    -
    -  showTransformDialog: function() {
    -    var $el = $('.dialog-content');
    -    util.show('dialog');
    -    var view = new recline.View.DataTransform({
    -    });
    -    view.render();
    -    $el.empty();
    -    $el.append(view.el);
    -    util.observeExit($el, function() {
    -      util.hide('dialog');
    -    });
    -    $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' });
    +    this.el.append(view.el);
    +    view.el.modal();
       },
     
       setColumnSort: function(order) {
    @@ -131,7 +105,7 @@ from DOM) while id may be int

    hideColumn: function() { var hiddenFields = this.state.get('hiddenFields'); hiddenFields.push(this.tempState.currentColumn); - this.state.set({hiddenFields: hiddenFields}); + this.state.set({hiddenFields: hiddenFields});

    change event not being triggered (because it is an array?) so trigger manually

        this.state.trigger('change');
         this.render();
       },
       
    @@ -139,7 +113,7 @@ from DOM) while id may be int

    var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column')); this.state.set({hiddenFields: hiddenFields}); this.render(); - },

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

    + },

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

    Templating

      template: ' \
         <table class="recline-grid table-striped table-condensed" cellspacing="0"> \
    @@ -183,7 +157,7 @@ from DOM) while id may be int

    toTemplateJSON: function() { var modelData = this.model.toJSON(); - modelData.notEmpty = ( this.fields.length > 0 );

    TODO: move this sort of thing into a toTemplateJSON method on Dataset?

        modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); });
    +    modelData.notEmpty = ( this.fields.length > 0 );

    TODO: move this sort of thing into a toTemplateJSON method on Dataset?

        modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); });
         return modelData;
       },
       render: function() {
    @@ -203,10 +177,10 @@ from DOM) while id may be int

    }); newView.render(); }); - this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); + this.el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); return this; } -});

    GridRow View for rendering an individual document.

    +});

    GridRow View for rendering an individual document.

    Since we want this to update in place it is up to creator to provider the element to attach to.

    @@ -269,8 +243,20 @@ var row = new GridRow({ var html = $.mustache(this.template, this.toTemplateJSON()); $(this.el).html(html); return this; - },

    =================== -Cell Editor methods

      onEditClick: function(e) {
    +  },

    =================== +Cell Editor methods

      cellEditorTemplate: ' \
    +    <div class="menu-container data-table-cell-editor"> \
    +      <textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
    +      <div id="data-table-cell-editor-actions"> \
    +        <div class="data-table-cell-editor-action"> \
    +          <button class="okButton btn primary">Update</button> \
    +          <button class="cancelButton btn danger">Cancel</button> \
    +        </div> \
    +      </div> \
    +    </div> \
    +  ',
    +
    +  onEditClick: function(e) {
         var editing = this.el.find('.data-table-cell-editor-editor');
         if (editing.length > 0) {
           editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden");
    @@ -278,10 +264,12 @@ Cell Editor methods

    $(e.target).addClass("hidden"); var cell = $(e.target).siblings('.data-table-cell-value'); cell.data("previousContents", cell.text()); - util.render('cellEditor', cell, {value: cell.text()}); + var templated = $.mustache(this.cellEditorTemplate, {value: cell.text()}); + cell.html(templated); }, onEditorOK: function(e) { + var self = this; var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); var field = cell.parents('td').attr('data-field'); @@ -289,12 +277,13 @@ Cell Editor methods

    var newData = {}; newData[field] = newValue; this.model.set(newData); - my.notify("Updating row...", {loader: true}); + this.trigger('recline:flash', {message: "Updating row...", loader: true}); this.model.save().then(function(response) { - my.notify("Row updated successfully", {category: 'success'}); + this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'}); }) .fail(function() { - my.notify('Error saving row', { + this.trigger('recline:flash', { + message: 'Error saving row', category: 'error', persist: true }); diff --git a/docs/view-map.html b/docs/view-map.html index 73faab44..e85e46e3 100644 --- a/docs/view-map.html +++ b/docs/view-map.html @@ -15,7 +15,7 @@ have the following (optional) configuration options:

       {
    -    // geomField if specified will be used in preference to lat/lon 
    +    // geomField if specified will be used in preference to lat/lon
         geomField: {id of field containing geometry in the dataset}
         lonField: {id of field containing longitude in the dataset}
         latField: {id of field containing latitude in the dataset}
    @@ -72,6 +72,11 @@ have the following (optional) configuration options:

    <div class="editor-buttons"> \ <button class="btn editor-update-map">Update</button> \ </div> \ + <div class="editor-options" > \ + <label class="checkbox"> \ + <input type="checkbox" id="editor-auto-zoom" checked="checked" /> \ + Auto zoom to features</label> \ + </div> \ <input type="hidden" class="editor-id" value="map-1" /> \ </div> \ </form> \ @@ -83,7 +88,8 @@ If not found, the user will need to define the fields via the editor.

    longitudeFieldNames: ['lon','longitude'], geometryFieldNames: ['geom','the_geom','geometry','spatial','location'],

    Define here events for UI elements

      events: {
         'click .editor-update-map': 'onEditorSubmit',
    -    'change .editor-field-type': 'onFieldTypeChange'
    +    'change .editor-field-type': 'onFieldTypeChange',
    +    'change #editor-auto-zoom': 'onAutoZoomChange'
       },
     
       initialize: function(options) {
    @@ -96,12 +102,25 @@ If not found, the user will need to define the fields via the editor.

    self._setupGeometryField() self.render() });

    Listen to changes in the documents

        this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)});
    +    this.model.currentDocuments.bind('change', function(doc){
    +        self.redraw('remove',doc);
    +        self.redraw('add',doc);
    +    });
         this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)});
    -    this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});

    If the div was hidden, Leaflet needs to recalculate some sizes -to display properly

        this.bind('view:show',function(){
    -        if (self.map) {
    -          self.map.invalidateSize();
    +    this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});
    +
    +    this.bind('view:show',function(){

    If the div was hidden, Leaflet needs to recalculate some sizes +to display properly

          if (self.map){
    +        self.map.invalidateSize();
    +        if (self._zoomPending && self.autoZoom) {
    +          self._zoomToFeatures();
    +          self._zoomPending = false;
             }
    +      }
    +      self.visible = true;
    +    });
    +    this.bind('view:hide',function(){
    +      self.visible = false;
         });
     
         var stateData = _.extend({
    @@ -113,6 +132,7 @@ to display properly

    ); this.state = new recline.Model.ObjectState(stateData); + this.autoZoom = true; this.mapReady = false; this.render(); },

    Public: Adds the necessary elements to the page.

    @@ -173,6 +193,13 @@ to display properly

    this.features.clearLayers(); this._add(this.model.currentDocuments.models); } + if (action != 'reset' && this.autoZoom){ + if (this.visible){ + this._zoomToFeatures(); + } else { + this._zoomPending = true; + } + } } },

    UI Event handlers

    Public: Update map with user options

    @@ -205,6 +232,10 @@ type selected.

    $('.editor-field-type-geom').hide(); $('.editor-field-type-latlon').show(); } + }, + + onAutoZoomChange: function(e){ + this.autoZoom = !this.autoZoom; },

    Private: Add one or n features to the map

    For each document passed, a GeoJSON geometry will be extracted and added @@ -212,7 +243,6 @@ to the features layer. If an exception is thrown, the process will be stopped and an error notification shown.

    Each feature will have a popup associated with all the document fields.

      _add: function(docs){
    -
         var self = this;
     
         if (!(docs instanceof Array)) docs = [docs];
    @@ -226,7 +256,9 @@ stopped and an error notification shown.

    } else if (feature instanceof Object){

    Build popup contents TODO: mustache?

            html = ''
             for (key in doc.attributes){
    -          html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>'
    +          if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
    +            html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
    +          }
             }
             feature.properties = {popupContent: html};

    Add a reference to the model id, which will allow us to link this Leaflet layer to a Recline doc

            feature.properties.cid = doc.cid;
    @@ -238,13 +270,13 @@ link this Leaflet layer to a Recline doc

    var msg = 'Wrong geometry value'; if (except.message) msg += ' (' + except.message + ')'; if (wrongSoFar <= 10) { - my.notify(msg,{category:'error'}); + self.trigger('recline:flash', {message: msg, category:'error'}); } } } else { wrongSoFar += 1 if (wrongSoFar <= 10) { - my.notify('Wrong geometry value',{category:'error'}); + self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'}); } } return true; @@ -265,27 +297,28 @@ link this Leaflet layer to a Recline doc

    },

    Private: Return a GeoJSON geomtry extracted from the document fields

      _getGeometryFromDocument: function(doc){
         if (this.geomReady){
    -      if (this.state.get('geomField')){

    We assume that the contents of the field are a valid GeoJSON object

            return doc.attributes[this.state.get('geomField')];
    -      } else if (this.state.get('lonField') && this.state.get('latField')){

    We'll create a GeoJSON like point object from the two lat/lon fields

            var lon = doc.get(this.state.get('lonField'));
    +      if (this.state.get('geomField')){
    +        var value = doc.get(this.state.get('geomField'));
    +        if (typeof(value) === 'string'){

    We have a GeoJSON string representation

              return $.parseJSON(value);
    +        } else {

    We assume that the contents of the field are a valid GeoJSON object

              return value;
    +        }
    +      } else if (this.state.get('lonField') && this.state.get('latField')){

    We'll create a GeoJSON like point object from the two lat/lon fields

            var lon = doc.get(this.state.get('lonField'));
             var lat = doc.get(this.state.get('latField'));
    -        if (lon && lat) {
    +        if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
               return {
                 type: 'Point',
    -            coordinates: [
    -              doc.attributes[this.state.get('lonField')],
    -              doc.attributes[this.state.get('latField')]
    -              ]
    +            coordinates: [lon,lat]
               };
             }
           }
           return null;
         }
    -  },

    Private: Check if there is a field with GeoJSON geometries or alternatively, + },

    Private: Check if there is a field with GeoJSON geometries or alternatively, two fields with lat/lon values.

    If not found, the user can define them via the UI form.

      _setupGeometryField: function(){
         var geomField, latField, lonField;
    -    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));

    should not overwrite if we have already set this (e.g. explicitly via state)

        if (!this.geomReady) {
    +    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));

    should not overwrite if we have already set this (e.g. explicitly via state)

        if (!this.geomReady) {
           this.state.set({
             geomField: this._checkField(this.geometryFieldNames),
             latField: this._checkField(this.latitudeFieldNames),
    @@ -293,7 +326,7 @@ two fields with lat/lon values.

    }); this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); } - },

    Private: Check if a field in the current model exists in the provided + },

    Private: Check if a field in the current model exists in the provided list of names.

      _checkField: function(fieldNames){
         var field;
         var modelFieldNames = this.model.fields.pluck('id');
    @@ -304,7 +337,15 @@ list of names.

    } } return null; - },

    Private: Sets up the Leaflet map control and the features layer.

    + },

    Private: Zoom to map to current features extent if any, or to the full +extent if none.

      _zoomToFeatures: function(){
    +    var bounds = this.features.getBounds();
    +    if (bounds){
    +      this.map.fitBounds(bounds);
    +    } else {
    +      this.map.setView(new L.LatLng(0, 0), 2);
    +    }
    +  },

    Private: Sets up the Leaflet map control and the features layer.

    The map uses a base layer from MapQuest based on OpenStreetMap.

      _setupMap: function(){
    @@ -325,13 +366,28 @@ on OpenStreetMap.

    e.layer.cid = e.properties.cid; } - }); + });

    This will be available in the next Leaflet stable release. +In the meantime we add it manually to our layer.

        this.features.getBounds = function(){
    +      var bounds = new L.LatLngBounds();
    +      this._iterateLayers(function (layer) {
    +        if (layer instanceof L.Marker){
    +          bounds.extend(layer.getLatLng());
    +        } else {
    +          if (layer.getBounds){
    +            bounds.extend(layer.getBounds().getNorthEast());
    +            bounds.extend(layer.getBounds().getSouthWest());
    +          }
    +        }
    +      }, this);
    +      return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
    +    }
    +
         this.map.addLayer(this.features);
     
         this.map.setView(new L.LatLng(0, 0), 2);
     
         this.mapReady = true;
    -  },

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
    +  },

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
         var options = $('.' + id + ' > select > option');
         if (options){
           options.each(function(opt){
    diff --git a/docs/view.html b/docs/view.html
    index 34f9cb34..dcb33dd2 100644
    --- a/docs/view.html
    +++ b/docs/view.html
    @@ -1,9 +1,10 @@
           view.js           
    this.el.find('.header').append(facetViewer.el);}, - setupRouting:function(){ - varself=this;varviewName=$(e.target).attr('data-view');this.updateNav(viewName);this.state.set({currentView:viewName}); - },},_bindStateChanges:function(){ - varself=this;self.state.set(update);pageView.view.state.bind('change',function(){varupdate={}; - update['view-'+pageView.id]=pageView.view.state.toJSON(); - self.state.set(update); + update['view-'+pageView.id]=pageView.view.state.toJSON();}}); -/* ========================================================== */

    view.js

    /*jshint multistr:true */

    Recline Views

    -

    Recline Views are Backbone Views and in keeping with normal Backbone views -are Widgets / Components displaying something in the DOM. Like all Backbone -views they have a pointer to a model or a collection and is bound to an -element.

    +

    Recline Views are instances of Backbone Views and they act as 'WUI' (web +user interface) component displaying some model object in the DOM. Like all +Backbone views they have a pointer to a model (or a collection) and have an +associated DOM-style element (usually this element will be bound into the +page at some point).

    Views provided by core Recline are crudely divided into two types:

    @@ -165,12 +166,6 @@ initialized the DataExplorer with the relevant views themselves.

    <div class="clearfix"></div> \ </div> \ <div class="data-view-container"></div> \ - <div class="dialog-overlay" style="display: none; z-index: 101; ">&nbsp;</div> \ - <div class="dialog ui-draggable" style="display: none; z-index: 102; top: 101px; "> \ - <div class="dialog-frame" style="width: 700px; visibility: visible; "> \ - <div class="dialog-content dialog-border"></div> \ - </div> \ - </div> \ </div> \ ', events: { @@ -207,7 +202,8 @@ initialized the DataExplorer with the relevant views themselves.

    }), }]; }

    these must be called after pageViews are created

        this.render();
    -    this._bindStateChanges();

    now do updates based on state (need to come after render)

        if (this.state.get('readOnly')) {
    +    this._bindStateChanges();
    +    this._bindFlashNotifications();

    now do updates based on state (need to come after render)

        if (this.state.get('readOnly')) {
           this.setReadOnly();
         }
         if (this.state.get('currentView')) {
    @@ -216,20 +212,16 @@ initialized the DataExplorer with the relevant views themselves.

    this.updateNav(this.pageViews[0].id); } - this.router = new Backbone.Router(); - this.setupRouting(); - this.model.bind('query:start', function() { - my.notify('Loading data', {loader: true}); + self.notify({message: 'Loading data', loader: true}); }); this.model.bind('query:done', function() { - my.clearNotifications(); + self.clearNotifications(); self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - my.notify('Data loaded', {category: 'success'});

    update navigation

            var qs = my.parseHashQueryString();
    -        qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON());
    -        var out = my.getNewHashForQueryString(qs);

    self.router.navigate(out);

          });
    +        self.notify({message: 'Data loaded', category: 'success'});
    +      });
         this.model.bind('query:fail', function(error) {
    -        my.clearNotifications();
    +        self.clearNotifications();
             var msg = '';
             if (typeof(error) == 'string') {
               msg = error;
    @@ -243,14 +235,14 @@ initialized the DataExplorer with the relevant views themselves.

    } else { msg = 'There was an error querying the backend'; } - my.notify(msg, {category: 'error', persist: true}); - });

    retrieve basic data like fields etc + self.notify({message: msg, category: 'error', persist: true}); + });

    retrieve basic data like fields etc note this.model and dataset returned are the same

        this.model.fetch()
           .done(function(dataset) {
             self.model.query(self.state.get('query'));
           })
           .fail(function(error) {
    -        my.notify(error.message, {category: 'error', persist: true});
    +        self.notify({message: error.message, category: 'error', persist: true});
           });
       },
     
    @@ -283,25 +275,12 @@ note this.model and dataset returned are the same

    Default route - this.router.route(/^(\?.)?$/, this.pageViews[0].id, function(queryString) { - self.updateNav(self.pageViews[0].id, queryString); - }); - $.each(this.pageViews, function(idx, view) { - self.router.route(/^([^?]+)(\?.)?/, 'view', function(viewId, queryString) { - self.updateNav(viewId, queryString); - }); - });

        this.router.route(/.*/, 'view', function() {
    -    });
    -  },
    -
       updateNav: function(pageName) {
         this.el.find('.navigation li').removeClass('active');
         this.el.find('.navigation li a').removeClass('disabled');
         var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]');
         $el.parent().addClass('active');
    -    $el.addClass('disabled');

    show the specific page

        _.each(this.pageViews, function(view, idx) {
    +    $el.addClass('disabled');

    show the specific page

        _.each(this.pageViews, function(view, idx) {
           if (view.id === pageName) {
             view.view.el.show();
             view.view.trigger('view:show');
    @@ -327,15 +306,15 @@ note this.model and dataset returned are the same

    create a state object for this view and do the job of

    + },

    create a state object for this view and do the job of

    a) initializing it from both data passed in and other sources (e.g. hash url)

    b) ensure the state object is updated in responese to changes in subviews, query etc.

      _setupState: function(initialState) {
    -    var self = this;

    get data from the query string / hash url plus some defaults

        var qs = my.parseHashQueryString();
    +    var self = this;

    get data from the query string / hash url plus some defaults

        var qs = recline.Util.parseHashQueryString();
         var query = qs.reclineQuery;
    -    query = query ? JSON.parse(query) : self.model.queryState.toJSON();

    backwards compatability (now named view-graph but was named graph)

        var graphState = qs['view-graph'] || qs.graph;
    -    graphState = graphState ? JSON.parse(graphState) : {};

    now get default data + hash url plus initial state and initial our state object with it

        var stateData = _.extend({
    +    query = query ? JSON.parse(query) : self.model.queryState.toJSON();

    backwards compatability (now named view-graph but was named graph)

        var graphState = qs['view-graph'] || qs.graph;
    +    graphState = graphState ? JSON.parse(graphState) : {};

    now get default data + hash url plus initial state and initial our state object with it

        var stateData = _.extend({
             query: query,
             'view-graph': graphState,
             backend: this.model.backend.__type__,
    @@ -348,7 +327,7 @@ note this.model and dataset returned are the same

    finally ensure we update our state object when state of sub-object changes so that state is always up to date

        this.model.queryState.bind('change', function() {
    +    var self = this;

    finally ensure we update our state object when state of sub-object changes so that state is always up to date

        this.model.queryState.bind('change', function() {
           self.state.set({query: self.model.queryState.toJSON()});
         });
         _.each(this.pageViews, function(pageView) {
    @@ -358,11 +337,58 @@ note this.model and dataset returned are the same

    had problems where change not being triggered for e.g. grid view so let's do it explicitly

              self.state.set(update, {silent: true});
    +          self.state.trigger('change');
             });
           }
         });
    +  },
    +
    +  _bindFlashNotifications: function() {
    +    var self = this;
    +    _.each(this.pageViews, function(pageView) {
    +      pageView.view.bind('recline:flash', function(flash) {
    +        self.notify(flash); 
    +      });
    +    });
    +  },

    notify

    + +

    Create a notification (a div.alert in div.alert-messsages) using provided +flash object. Flash attributes (all are optional):

    + +
      +
    • message: message to show.
    • +
    • category: warning (default), success, error
    • +
    • persist: if true alert is persistent, o/w hidden after 3s (default = false)
    • +
    • loader: if true show loading spinner
    • +
      notify: function(flash) {
    +    var tmplData = _.extend({
    +      message: '',
    +      category: 'warning'
    +      },
    +      flash
    +    );
    +    var _template = ' \
    +      <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
    +        {{message}} \
    +          {{#loader}} \
    +          <span class="notification-loader">&nbsp;</span> \
    +          {{/loader}} \
    +      </div>';
    +    var _templated = $.mustache(_template, tmplData); 
    +    _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
    +    if (!flash.persist) {
    +      setTimeout(function() {
    +        $(_templated).fadeOut(1000, function() {
    +          $(this).remove();
    +        });
    +      }, 1000);
    +    }
    +  },

    clearNotifications

    + +

    Clear all existing notifications

      clearNotifications: function() {
    +    var $notifications = $('.recline-data-explorer .alert-messages .alert');
    +    $notifications.remove();
       }
     });

    DataExplorer.restore

    @@ -594,95 +620,6 @@ note this.model and dataset returned are the same

    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 {};
    -  } else {
    -    return {
    -      path: parsed[1],
    -      query: parsed[2] || ''
    -    };
    -  }
    -};

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

    my.parseQueryString = function(q) {
    -  if (!q) {
    -    return {};
    -  }
    -  var urlParams = {},
    -    e, d = function (s) {
    -      return unescape(s.replace(/\+/g, " "));
    -    },
    -    r = /([^&=]+)=?([^&]*)/g;
    -
    -  if (q && q.length && q[0] === '?') {
    -    q = q.slice(1);
    -  }
    -  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() {
    -  q = my.parseHashUrl(window.location.hash).query;
    -  return my.parseQueryString(q);
    -};

    Compse a Query String

    my.composeQueryString = function(queryParams) {
    -  var queryString = '?';
    -  var items = [];
    -  $.each(queryParams, function(key, value) {
    -    if (typeof(value) === 'object') {
    -      value = JSON.stringify(value);
    -    }
    -    items.push(key + '=' + value);
    -  });
    -  queryString += items.join('&');
    -  return queryString;
    -};
    -
    -my.getNewHashForQueryString = function(queryParams) {
    -  var queryPart = my.composeQueryString(queryParams);
    -  if (window.location.hash) {

    slice(1) to remove # at start

        return window.location.hash.split('?')[0].slice(1) + queryPart;
    -  } else {
    -    return queryPart;
    -  }
    -};
    -
    -my.setHashQueryString = function(queryParams) {
    -  window.location.hash = my.getNewHashForQueryString(queryParams);
    -};

    notify

    - -

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

    - -
      -
    • category: warning (default), success, error
    • -
    • 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) options = {};
    -  var tmplData = _.extend({
    -    msg: message,
    -    category: 'warning'
    -    },
    -    options);
    -  var _template = ' \
    -    <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
    -      {{msg}} \
    -        {{#loader}} \
    -        <span class="notification-loader">&nbsp;</span> \
    -        {{/loader}} \
    -    </div>';
    -  var _templated = $.mustache(_template, tmplData); 
    -  _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
    -  if (!options.persist) {
    -    setTimeout(function() {
    -      $(_templated).fadeOut(1000, function() {
    -        $(this).remove();
    -      });
    -    }, 1000);
    -  }
    -};

    clearNotifications

    - -

    Clear all existing notifications

    my.clearNotifications = function() {
    -  var $notifications = $('.recline-data-explorer .alert-messages .alert');
    -  $notifications.remove();
    -};
     
     })(jQuery, recline.View);
     
    diff --git a/recline.js b/recline.js
    index 271e9c54..261de327 100644
    --- a/recline.js
    +++ b/recline.js
    @@ -556,157 +556,83 @@ my.backends = {};
     
     /*jshint multistr:true */
     
    -var util = function() {
    -  var templates = {
    -    transformActions: '
  • Global transform...
  • ', - cellEditor: ' \ - \ - ', - editPreview: ' \ -
    \ - \ - \ - \ - \ - \ - \ - \ - \ - {{#rows}} \ - \ - \ - \ - \ - {{/rows}} \ - \ -
    \ - before \ - \ - after \ -
    \ - {{before}} \ - \ - {{after}} \ -
    \ -
    \ - ' - }; +this.recline = this.recline || {}; +this.recline.Util = this.recline.Util || {}; - $.fn.serializeObject = function() { - var o = {}; - var a = this.serializeArray(); - $.each(a, function() { - if (o[this.name]) { - if (!o[this.name].push) { - o[this.name] = [o[this.name]]; - } - o[this.name].push(this.value || ''); - } else { - o[this.name] = this.value || ''; - } - }); - return o; - }; +(function(my) { +// ## Miscellaneous Utilities - function registerEmitter() { - var Emitter = function(obj) { - this.emit = function(obj, channel) { - if (!channel) channel = 'data'; - this.trigger(channel, obj); - }; +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 {}; + } else { + return { + path: parsed[1], + query: parsed[2] || '' }; - MicroEvent.mixin(Emitter); - return new Emitter(); - } - - function listenFor(keys) { - var shortcuts = { // from jquery.hotkeys.js - 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", - 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", - 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del", - 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", - 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); - }, false); - } - - function observeExit(elem, callback) { - var cancelButton = elem.find('.cancelButton'); - // TODO: remove (commented out as part of Backbon-i-fication - // app.emitter.on('esc', function() { - // cancelButton.click(); - // app.emitter.clear('esc'); - // }); - cancelButton.click(callback); - } - - function show( thing ) { - $('.' + thing ).show(); - $('.' + thing + '-overlay').show(); } +}; - function hide( thing ) { - $('.' + thing ).hide(); - $('.' + thing + '-overlay').hide(); - // TODO: remove or replace (commented out as part of Backbon-i-fication - // if (thing === "dialog") app.emitter.clear('esc'); // todo more elegant solution - } - - function position( thing, elem, offset ) { - var position = $(elem.target).position(); - if (offset) { - if (offset.top) position.top += offset.top; - if (offset.left) position.left += offset.left; - } - $('.' + thing + '-overlay').show().click(function(e) { - $(e.target).hide(); - $('.' + thing).hide(); - }); - $('.' + thing).show().css({top: position.top + $(elem.target).height(), left: position.left}); +// Parse a URL query string (?xyz=abc...) into a dictionary. +my.parseQueryString = function(q) { + if (!q) { + return {}; } + var urlParams = {}, + e, d = function (s) { + return unescape(s.replace(/\+/g, " ")); + }, + r = /([^&=]+)=?([^&]*)/g; - function render( template, target, options ) { - if ( !options ) options = {data: {}}; - if ( !options.data ) options = {data: options}; - var html = $.mustache( templates[template], options.data ); - var targetDom = null; - if (target instanceof jQuery) { - targetDom = target; - } else { - targetDom = $( "." + target + ":first" ); - } - if( options.append ) { - targetDom.append( html ); - } else { - targetDom.html( html ); - } - // TODO: remove (commented out as part of Backbon-i-fication - // if (template in app.after) app.after[template](); + if (q && q.length && q[0] === '?') { + q = q.slice(1); } + 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() { + q = my.parseHashUrl(window.location.hash).query; + return my.parseQueryString(q); +}; + +// Compse a Query String +my.composeQueryString = function(queryParams) { + var queryString = '?'; + var items = []; + $.each(queryParams, function(key, value) { + if (typeof(value) === 'object') { + value = JSON.stringify(value); + } + items.push(key + '=' + value); + }); + queryString += items.join('&'); + return queryString; +}; + +my.getNewHashForQueryString = function(queryParams) { + var queryPart = my.composeQueryString(queryParams); + if (window.location.hash) { + // slice(1) to remove # at start + return window.location.hash.split('?')[0].slice(1) + queryPart; + } else { + return queryPart; + } +}; + +my.setHashQueryString = function(queryParams) { + window.location.hash = my.getNewHashForQueryString(queryParams); +}; +})(this.recline.Util); - return { - registerEmitter: registerEmitter, - listenFor: listenFor, - show: show, - hide: hide, - position: position, - render: render, - observeExit: observeExit - }; -}(); /*jshint multistr:true */ this.recline = this.recline || {}; @@ -791,7 +717,6 @@ my.Graph = Backbone.View.extend({ \
    \ \ +
    \ +
    \ + \ + \ +
    \ +
    \ +
    \ + ', + onEditClick: function(e) { var editing = this.el.find('.data-table-cell-editor-editor'); if (editing.length > 0) { @@ -1405,10 +1334,12 @@ my.GridRow = Backbone.View.extend({ $(e.target).addClass("hidden"); var cell = $(e.target).siblings('.data-table-cell-value'); cell.data("previousContents", cell.text()); - util.render('cellEditor', cell, {value: cell.text()}); + var templated = $.mustache(this.cellEditorTemplate, {value: cell.text()}); + cell.html(templated); }, onEditorOK: function(e) { + var self = this; var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); var field = cell.parents('td').attr('data-field'); @@ -1416,12 +1347,13 @@ my.GridRow = Backbone.View.extend({ var newData = {}; newData[field] = newValue; this.model.set(newData); - my.notify("Updating row...", {loader: true}); + this.trigger('recline:flash', {message: "Updating row...", loader: true}); this.model.save().then(function(response) { - my.notify("Row updated successfully", {category: 'success'}); + this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'}); }) .fail(function() { - my.notify('Error saving row', { + this.trigger('recline:flash', { + message: 'Error saving row', category: 'error', persist: true }); @@ -1718,7 +1650,6 @@ my.Map = Backbone.View.extend({ // Each feature will have a popup associated with all the document fields. // _add: function(docs){ - var self = this; if (!(docs instanceof Array)) docs = [docs]; @@ -1753,13 +1684,13 @@ my.Map = Backbone.View.extend({ var msg = 'Wrong geometry value'; if (except.message) msg += ' (' + except.message + ')'; if (wrongSoFar <= 10) { - my.notify(msg,{category:'error'}); + self.trigger('recline:flash', {message: msg, category:'error'}); } } } else { wrongSoFar += 1 if (wrongSoFar <= 10) { - my.notify('Wrong geometry value',{category:'error'}); + self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'}); } } return true; @@ -1864,7 +1795,7 @@ my.Map = Backbone.View.extend({ // on [OpenStreetMap](http://openstreetmap.org). // _setupMap: function(){ - + var self = this; this.map = new L.Map(this.$map.get(0)); var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png"; @@ -1904,6 +1835,14 @@ my.Map = Backbone.View.extend({ this.map.setView(new L.LatLng(0, 0), 2); + var popup = new L.Popup(); + this.map.on('click', function(e) { + var latlngStr = '(' + e.latlng.lat.toFixed(3) + ', ' + e.latlng.lng.toFixed(3) + ')'; + popup.setLatLng(e.latlng); + popup.setContent("You clicked the map at " + latlngStr); + self.map.openPopup(popup); + }); + this.mapReady = true; }, @@ -1933,82 +1872,17 @@ this.recline.View = this.recline.View || {}; // Views module following classic module pattern (function($, my) { -// View (Dialog) for doing data transformations on whole dataset. -my.DataTransform = Backbone.View.extend({ - className: 'transform-view', - template: ' \ -
    \ - Recursive transform on all rows \ -
    \ -
    \ -
    \ -

    Traverse and transform objects by visiting every node on a recursive walk using js-traverse.

    \ - \ - \ - \ - \ - \ - \ -
    \ -
    \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ -
    \ - Expression \ -
    \ -
    \ - \ -
    \ -
    \ - No syntax error. \ -
    \ -
    \ - Preview \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ - \ - ', - - initialize: function() { - this.el = $(this.el); - }, - - render: function() { - this.el.html(this.template); - } -}); - - +// ## ColumnTransform +// // View (Dialog) for doing data transformations (on columns of data). my.ColumnTransform = Backbone.View.extend({ - className: 'transform-column-view', + className: 'transform-column-view modal fade in', template: ' \ -
    \ - Functional transform on column {{name}} \ + \ -
    \ + \ - \
    \ - \ - \
    \ ', events: { @@ -2350,6 +2250,7 @@ my.DataExplorer = Backbone.View.extend({ // these must be called after pageViews are created this.render(); this._bindStateChanges(); + this._bindFlashNotifications(); // now do updates based on state (need to come after render) if (this.state.get('readOnly')) { this.setReadOnly(); @@ -2360,24 +2261,16 @@ my.DataExplorer = Backbone.View.extend({ this.updateNav(this.pageViews[0].id); } - this.router = new Backbone.Router(); - this.setupRouting(); - this.model.bind('query:start', function() { - my.notify('Loading data', {loader: true}); + self.notify({message: 'Loading data', loader: true}); }); this.model.bind('query:done', function() { - my.clearNotifications(); + self.clearNotifications(); self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - my.notify('Data loaded', {category: 'success'}); - // update navigation - var qs = my.parseHashQueryString(); - qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON()); - var out = my.getNewHashForQueryString(qs); - // self.router.navigate(out); + self.notify({message: 'Data loaded', category: 'success'}); }); this.model.bind('query:fail', function(error) { - my.clearNotifications(); + self.clearNotifications(); var msg = ''; if (typeof(error) == 'string') { msg = error; @@ -2391,7 +2284,7 @@ my.DataExplorer = Backbone.View.extend({ } else { msg = 'There was an error querying the backend'; } - my.notify(msg, {category: 'error', persist: true}); + self.notify({message: msg, category: 'error', persist: true}); }); // retrieve basic data like fields etc @@ -2401,7 +2294,7 @@ my.DataExplorer = Backbone.View.extend({ self.model.query(self.state.get('query')); }) .fail(function(error) { - my.notify(error.message, {category: 'error', persist: true}); + self.notify({message: error.message, category: 'error', persist: true}); }); }, @@ -2434,21 +2327,6 @@ my.DataExplorer = Backbone.View.extend({ this.el.find('.header').append(facetViewer.el); }, - setupRouting: function() { - var self = this; - // Default route -// this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) { -// self.updateNav(self.pageViews[0].id, queryString); -// }); -// $.each(this.pageViews, function(idx, view) { -// self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { -// self.updateNav(viewId, queryString); -// }); -// }); - this.router.route(/.*/, 'view', function() { - }); - }, - updateNav: function(pageName) { this.el.find('.navigation li').removeClass('active'); this.el.find('.navigation li a').removeClass('disabled'); @@ -2492,7 +2370,7 @@ my.DataExplorer = Backbone.View.extend({ _setupState: function(initialState) { var self = this; // get data from the query string / hash url plus some defaults - var qs = my.parseHashQueryString(); + var qs = recline.Util.parseHashQueryString(); var query = qs.reclineQuery; query = query ? JSON.parse(query) : self.model.queryState.toJSON(); // backwards compatability (now named view-graph but was named graph) @@ -2532,6 +2410,57 @@ my.DataExplorer = Backbone.View.extend({ }); } }); + }, + + _bindFlashNotifications: function() { + var self = this; + _.each(this.pageViews, function(pageView) { + pageView.view.bind('recline:flash', function(flash) { + self.notify(flash); + }); + }); + }, + + // ### notify + // + // Create a notification (a div.alert in div.alert-messsages) using provided + // flash object. Flash attributes (all are optional): + // + // * message: message to show. + // * category: warning (default), success, error + // * persist: if true alert is persistent, o/w hidden after 3s (default = false) + // * loader: if true show loading spinner + notify: function(flash) { + var tmplData = _.extend({ + message: '', + category: 'warning' + }, + flash + ); + var _template = ' \ +
    × \ + {{message}} \ + {{#loader}} \ +   \ + {{/loader}} \ +
    '; + var _templated = $.mustache(_template, tmplData); + _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); + if (!flash.persist) { + setTimeout(function() { + $(_templated).fadeOut(1000, function() { + $(this).remove(); + }); + }, 1000); + } + }, + + // ### clearNotifications + // + // Clear all existing notifications + clearNotifications: function() { + var $notifications = $('.recline-data-explorer .alert-messages .alert'); + $notifications.remove(); } }); @@ -2772,118 +2701,6 @@ my.FacetViewer = Backbone.View.extend({ } }); -/* ========================================================== */ -// ## 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 {}; - } else { - return { - path: parsed[1], - query: parsed[2] || '' - }; - } -}; - -// Parse a URL query string (?xyz=abc...) into a dictionary. -my.parseQueryString = function(q) { - if (!q) { - return {}; - } - var urlParams = {}, - e, d = function (s) { - return unescape(s.replace(/\+/g, " ")); - }, - r = /([^&=]+)=?([^&]*)/g; - - if (q && q.length && q[0] === '?') { - q = q.slice(1); - } - 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() { - q = my.parseHashUrl(window.location.hash).query; - return my.parseQueryString(q); -}; - -// Compse a Query String -my.composeQueryString = function(queryParams) { - var queryString = '?'; - var items = []; - $.each(queryParams, function(key, value) { - if (typeof(value) === 'object') { - value = JSON.stringify(value); - } - items.push(key + '=' + value); - }); - queryString += items.join('&'); - return queryString; -}; - -my.getNewHashForQueryString = function(queryParams) { - var queryPart = my.composeQueryString(queryParams); - if (window.location.hash) { - // slice(1) to remove # at start - return window.location.hash.split('?')[0].slice(1) + queryPart; - } else { - return queryPart; - } -}; - -my.setHashQueryString = function(queryParams) { - window.location.hash = my.getNewHashForQueryString(queryParams); -}; - -// ## notify -// -// Create a notification (a div.alert in div.alert-messsages) using provide messages and options. Options are: -// -// * category: warning (default), success, error -// * 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) options = {}; - var tmplData = _.extend({ - msg: message, - category: 'warning' - }, - options); - var _template = ' \ -
    × \ - {{msg}} \ - {{#loader}} \ -   \ - {{/loader}} \ -
    '; - var _templated = $.mustache(_template, tmplData); - _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); - if (!options.persist) { - setTimeout(function() { - $(_templated).fadeOut(1000, function() { - $(this).remove(); - }); - }, 1000); - } -}; - -// ## clearNotifications -// -// Clear all existing notifications -my.clearNotifications = function() { - var $notifications = $('.recline-data-explorer .alert-messages .alert'); - $notifications.remove(); -}; })(jQuery, recline.View); @@ -3671,6 +3488,7 @@ this.recline.Backend = this.recline.Backend || {}; backend.addDataset(datasetInfo); var dataset = new recline.Model.Dataset({id: metadata.id}, backend); dataset.fetch(); + dataset.query(); return dataset; }; From 321fb53a8163058c294eeee048c1437bd9ae9c11 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 09:29:56 +0100 Subject: [PATCH 06/13] [README][s]: add detailed changelog. --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index ed2bc918..bcd93e3a 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,60 @@ Designed for standalone use or as a library to integrate into your own app. Running the tests by opening `test/index.html` in your browser. +## Changelog + +### v0.5 - Master + +In progress. + +### v0.4 - April 26th 2012 + +[23 closed issues](https://github.com/okfn/recline/issues?milestone=2&page=1&state=closed) including: + +* Map view using Leaflet - #69, #64, #89, #97 +* Term filter support - #66 +* Faceting support- #62 +* Tidy up CSS and JS - #81 and #78 +* Manage and serialize view and dataset state (plus support for embed and permalinks) - #88, #67 +* Graph view improvements e.g. handle date types correctly - #75 +* Write support for ES backend - #61 +* Remove JQuery-UI dependency in favour of bootstrap modal - #46 +* Improved CSV import support - #92 + +### v0.3 - March 31st 2012 + +[16 closed issues](https://github.com/okfn/recline/issues?milestone=1&state=closed) including: + +* ElasticSearch (and hence DataHub/CKAN) backend - #54 +* Loading of local CSV files - #36 +* Fully worked out Data Query support - #34, #49, #53, #57 +* New Field model object for richer field information - #25 +* Upgrade to Bootstrap v2.0 - #55 +* Recline Data Explorer app improvements e.g. #39 (import menu) +* Graph improvements - #58 (more graph types, graph interaction) + +### v0.2 - Feb 24th 2012 + +[17 closed issues](https://github.com/okfn/recline/issues?milestone=3&state=closed) including: + +* Major refactor of backend and model relationship - #35 and #43 +* Support Google Docs Spreadsheets as a Backend - #15 +* Support for online CSV and Excel files via DataProxy backend - #31 +* Data Explorer is customizable re loaded views - #42 +* Start of documentation - #33 +* Views in separate files - #41 +* Better error reporting from backends on JSONP errors - #30 +* Sorting and show/hide of columns in data grid - #23, #29 +* Support for pagination - #27 +* Split backends into separate files to make them easier to maintain and reuse separately #50 + +### v0.1 - Jan 28th 2012 + +* Core models and structure including Dataset and Document +* Memory and webstore backends +* Grid, Graph and Data Explorer views +* Bootstrap-based theme - #22 + ## Copyright and License Copyright 2011 Max Ogden and Rufus Pollock. From 6155ad9e601641c23e85683fc84955a032c5d57f Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 09:59:33 +0100 Subject: [PATCH 07/13] [#112,jekyll][xs]: add _config.yml. --- _config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 _config.yml diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000..c86cd02e --- /dev/null +++ b/_config.yml @@ -0,0 +1,5 @@ +pygments: true +auto: true + +title: Recline Data Explorer and Library + From 665755569443a23003e926cc14131025f85da80d Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 10:00:24 +0100 Subject: [PATCH 08/13] [app/style][xs]: add in height that has been removed from grid.css. --- app/style/demo.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/style/demo.css b/app/style/demo.css index aad87eca..a9023e24 100644 --- a/app/style/demo.css +++ b/app/style/demo.css @@ -7,3 +7,7 @@ body { margin-left: 0; } +.recline-grid-container { + height: 550px; +} + From 52c5442d372dda56eb636a9a40971100dd110373 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 13 May 2012 10:02:41 +0100 Subject: [PATCH 09/13] [#112,theme,jekyll][s]: new container template (good for pure markdown pages), pygments css and option to include recline deps in the main template (useful for examples). --- _includes/recline-deps.html | 32 +++++++++++++++++++ _layouts/container.html | 8 +++++ _layouts/default.html | 9 +++++- css/pygments.css | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 _includes/recline-deps.html create mode 100644 _layouts/container.html create mode 100644 css/pygments.css diff --git a/_includes/recline-deps.html b/_includes/recline-deps.html new file mode 100644 index 00000000..a03aa292 --- /dev/null +++ b/_includes/recline-deps.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/_layouts/container.html b/_layouts/container.html new file mode 100644 index 00000000..4abf7ae8 --- /dev/null +++ b/_layouts/container.html @@ -0,0 +1,8 @@ +--- +layout: default +--- + +
    + {{content}} +
    + diff --git a/_layouts/default.html b/_layouts/default.html index 8f60ae47..73a79abd 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -10,8 +10,15 @@ - + {% if page.recline-deps %} + {% include recline-deps.html %} + {% endif %} + + + + +