diff --git a/css/data-explorer.css b/css/data-explorer.css index 77aacfd6..ea5ba775 100644 --- a/css/data-explorer.css +++ b/css/data-explorer.css @@ -12,19 +12,39 @@ padding-left: 0; } -.header .pagination { +.header .recline-results-info { + line-height: 28px; + margin-left: 20px; + display: inline; +} + +.header .recline-query-editor { float: right; margin: 4px; } -.header .pagination label { +.header .recline-query-editor label { float: none; } -.header .pagination input { +.header .recline-query-editor label { + float: none; +} + +.header .recline-query-editor input.text-query { + float: left; + margin-top: 1px; + margin-right: 5px; +} + +.header .recline-query-editor .pagination input { width: 30px; } +.header .recline-query-editor .pagination a { + line-height: 28px; +} + .data-view-container { display: block; clear: both; diff --git a/demo/index.html b/demo/index.html index c941752a..328f01be 100644 --- a/demo/index.html +++ b/demo/index.html @@ -23,13 +23,7 @@ - - - - - - - + @@ -42,6 +36,11 @@
+
diff --git a/demo/js/app.js b/demo/js/app.js index d0360492..d3ed89ab 100755 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -6,7 +6,7 @@ $(function() { { id: 'grid', label: 'Grid', - view: new recline.View.DataTable({ + view: new recline.View.DataGrid({ model: dataset }) }, @@ -62,7 +62,7 @@ function demoDataset() { , {id: 5, x: 6, y: 12, z: 18} ] }; - var backend = new recline.Model.BackendMemory(); + var backend = new recline.Backend.Memory(); backend.addDataset(inData); var dataset = new recline.Model.Dataset({id: datasetId}, backend); return dataset; @@ -76,11 +76,13 @@ function setupLoadFromWebstore(callback) { e.preventDefault(); var $form = $(e.target); var source = $form.find('input[name="source"]').val(); + var type = $form.find('select[name="backend_type"]').val(); var dataset = new recline.Model.Dataset({ - id: 'gold-prices', + id: 'my-dataset', + url: source, webstore_url: source }, - 'webstore' + type ); callback(dataset); }); diff --git a/docs/backend.html b/docs/backend.html deleted file mode 100644 index b1f5009e..00000000 --- a/docs/backend.html +++ /dev/null @@ -1,353 +0,0 @@ - backend.js
Jump To …

backend.js

Recline Backends

- -

Backends are connectors to backend data sources and stores

- -

Backends are implemented as Backbone models but this is just a -convenience (they do not save or load themselves from any remote -source)

this.recline = this.recline || {};
-this.recline.Model = this.recline.Model || {};
-
-(function($, my) {

Backbone.sync

- -

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);
-  }

wrapInTimeout

- -

Crude way to catch backend errors -Many of backends use JSONP and so will not get error messages and this is -a crude way to catch those errors.

  function wrapInTimeout(ourFunction) {
-    var dfd = $.Deferred();
-    var timeout = 5000;
-    var timer = setTimeout(function() {
-      dfd.reject({
-        message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
-      });
-    }, timeout);
-    ourFunction.done(function(arguments) {
-        clearTimeout(timer);
-        dfd.resolve(arguments);
-      })
-      .fail(function(arguments) {
-        clearTimeout(timer);
-        dfd.reject(arguments);
-      })
-      ;
-    return dfd.promise();
-  }

BackendMemory - uses in-memory data

- -

This is very artificial and is really only designed for testing -purposes.

- -

To use it you should provide in your constructor data:

- -
    -
  • metadata (including fields array)
  • -
  • documents: list of hashes, each hash being one doc. A doc must have an id attribute which is unique.

    - -

    Example:

    - -
    -// Backend setup
    -var backend = Backend();
    -backend.addDataset({
    -metadata: {
    - id: 'my-id',
    - title: 'My Title'
    -},
    -fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
    -documents: [
    -   {id: 0, x: 1, y: 2, z: 3},
    -   {id: 1, x: 2, y: 4, z: 6}
    - ]
    -});
    -// later ...
    -var dataset = Dataset({id: 'my-id'});
    -dataset.fetch();
    -etc ...
    -
  • -
  my.BackendMemory = Backbone.Model.extend({
-    initialize: function() {
-      this.datasets = {};
-    },
-    addDataset: function(data) {
-      this.datasets[data.metadata.id] = $.extend(true, {}, data);
-    },
-    sync: function(method, model, options) {
-      var self = this;
-      if (method === "read") {
-        var dfd = $.Deferred();
-        if (model.__type__ == 'Dataset') {
-          var rawDataset = this.datasets[model.id];
-          model.set(rawDataset.metadata);
-          model.fields.reset(rawDataset.fields);
-          model.docCount = rawDataset.documents.length;
-          dfd.resolve(model);
-        }
-        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) {
-              self.datasets[model.dataset.id].documents[idx] = model.toJSON();
-            }
-          });
-          dfd.resolve(model);
-        }
-        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) {
-            return (doc.id === model.id);
-          });
-          rawDataset.documents = newdocs;
-          dfd.resolve(model);
-        }
-        return dfd.promise();
-      } else {
-        alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model);
-      }
-    },
-    query: function(model, queryObj) {
-      var numRows = queryObj.size;
-      var start = queryObj.offset;
-      var dfd = $.Deferred();
-      results = this.datasets[model.id].documents;

not complete sorting!

      _.each(queryObj.sort, function(item) {
-        results = _.sortBy(results, function(doc) {
-          var _out = doc[item[0]];
-          return (item[1] == 'asc') ? _out : -1*_out;
-        });
-      });
-      var results = results.slice(start, start+numRows);
-      dfd.resolve(results);
-      return dfd.promise();
-    }
-  });
-  my.backends['memory'] = new my.BackendMemory();

BackendWebstore

- -

Connecting to Webstores

- -

To use this backend ensure your Dataset has a webstore_url in its attributes.

  my.BackendWebstore = Backbone.Model.extend({
-    sync: function(method, model, options) {
-      if (method === "read") {
-        if (model.__type__ == 'Dataset') {
-          var base = model.get('webstore_url');
-          var schemaUrl = base + '/schema.json';
-          var jqxhr = $.ajax({
-            url: schemaUrl,
-              dataType: 'jsonp',
-              jsonp: '_callback'
-          });
-          var dfd = $.Deferred();
-          wrapInTimeout(jqxhr).done(function(schema) {
-            var fieldData = _.map(schema.data, function(item) {
-              item.id = item.name;
-              delete item.name;
-              return item;
-            });
-            model.fields.reset(fieldData);
-            model.docCount = schema.count;
-            dfd.resolve(model, jqxhr);
-          })
-          .fail(function(arguments) {
-            dfd.reject(arguments);
-          });
-          return dfd.promise();
-        }
-      }
-    },
-    query: function(model, queryObj) {
-      var base = model.get('webstore_url');
-      var data = {
-        _limit:  queryObj.size
-        , _offset: queryObj.offset
-      };
-      var jqxhr = $.ajax({
-        url: base + '.json',
-        data: data,
-        dataType: 'jsonp',
-        jsonp: '_callback',
-        cache: true
-      });
-      var dfd = $.Deferred();
-      jqxhr.done(function(results) {
-        dfd.resolve(results.data);
-      });
-      return dfd.promise();
-    }
-  });
-  my.backends['webstore'] = new my.BackendWebstore();

BackendDataProxy

- -

For connecting to DataProxy-s.

- -

When initializing the DataProxy backend you can set the following attributes:

- -
    -
  • dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com
  • -
- -

Datasets using using this backend should set the following attributes:

- -
    -
  • url: (required) url-of-data-to-proxy
  • -
  • format: (optional) csv | xls (defaults to csv if not specified)
  • -
- -

Note that this is a read-only backend.

  my.BackendDataProxy = Backbone.Model.extend({
-    defaults: {
-      dataproxy_url: 'http://jsonpdataproxy.appspot.com'
-    },
-    sync: function(method, model, options) {
-      var self = this;
-      if (method === "read") {
-        if (model.__type__ == 'Dataset') {
-          var base = self.get('dataproxy_url');

TODO: should we cache for extra efficiency

          var data = {
-            url: model.get('url')
-            , 'max-results':  1
-            , type: model.get('format') || 'csv'
-          };
-          var jqxhr = $.ajax({
-            url: base
-            , data: data
-            , dataType: 'jsonp'
-          });
-          var dfd = $.Deferred();
-          wrapInTimeout(jqxhr).done(function(results) {
-            model.fields.reset(_.map(results.fields, function(fieldId) {
-              return {id: fieldId};
-              })
-            );
-            dfd.resolve(model, jqxhr);
-          })
-          .fail(function(arguments) {
-            dfd.reject(arguments);
-          });
-          return dfd.promise();
-        }
-      } else {
-        alert('This backend only supports read operations');
-      }
-    },
-    query: function(dataset, queryObj) {
-      var base = this.get('dataproxy_url');
-      var data = {
-        url: dataset.get('url')
-        , 'max-results':  queryObj.size
-        , type: dataset.get('format')
-      };
-      var jqxhr = $.ajax({
-        url: base
-        , data: data
-        , dataType: 'jsonp'
-      });
-      var dfd = $.Deferred();
-      jqxhr.done(function(results) {
-        var _out = _.map(results.data, function(doc) {
-          var tmp = {};
-          _.each(results.fields, function(key, idx) {
-            tmp[key] = doc[idx];
-          });
-          return tmp;
-        });
-        dfd.resolve(_out);
-      });
-      return dfd.promise();
-    }
-  });
-  my.backends['dataproxy'] = new my.BackendDataProxy();

Google spreadsheet backend

- -

Connect to Google Docs spreadsheet.

- -

Dataset must have a url attribute pointing to the Gdocs -spreadsheet's JSON feed e.g.

- -
-var dataset = new recline.Model.Dataset({
-    url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
-  },
-  'gdocs'
-);
-
  my.BackendGDoc = Backbone.Model.extend({
-    sync: function(method, model, options) {
-      var self = this;
-      if (method === "read") { 
-        var dfd = $.Deferred(); 
-        var dataset = model;
-
-        $.getJSON(model.get('url'), function(d) {
-          result = self.gdocsToJavascript(d);
-          model.fields.reset(_.map(result.field, function(fieldId) {
-              return {id: fieldId};
-            })
-          );

cache data onto dataset (we have loaded whole gdoc it seems!)

          model._dataCache = result.data;
-          dfd.resolve(model);
-        })
-        return dfd.promise(); }
-    },
-
-    query: function(dataset, queryObj) { 
-      var dfd = $.Deferred();
-      var fields = _.pluck(dataset.fields.toJSON(), 'id');

zip the fields with the data rows to produce js objs -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]; })
-        return obj;
-      });
-      dfd.resolve(objs);
-      return dfd;
-    },
-    gdocsToJavascript:  function(gdocsSpreadsheet) {
-      /*
-         :options: (optional) optional argument dictionary:
-         columnsToUse: list of columns to use (specified by field names)
-         colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion).
-         :return: tabular data object (hash with keys: field and data).
-
-         Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.
-         */
-      var options = {};
-      if (arguments.length > 1) {
-        options = arguments[1];
-      }
-      var results = {
-        'field': [],
-        'data': []
-      };

default is no special info on type of columns

      var colTypes = {};
-      if (options.colTypes) {
-        colTypes = options.colTypes;
-      }

either extract column headings from spreadsheet directly, or used supplied ones

      if (options.columnsToUse) {

columns set to subset supplied

        results.field = options.columnsToUse;
-      } else {

set columns to use to be all available

        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);
-            }
-          }
-        }
-      }

converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])

      var rep = /^([\d\.\-]+)\%$/;
-      $.each(gdocsSpreadsheet.feed.entry, function (i, entry) {
-        var row = [];
-        for (var k in results.field) {
-          var col = results.field[k];
-          var _keyname = 'gsx$' + col;
-          var value = entry[_keyname]['$t'];

if labelled as % and value contains %, convert

          if (colTypes[col] == 'percent') {
-            if (rep.test(value)) {
-              var value2 = rep.exec(value);
-              var value3 = parseFloat(value2);
-              value = value3 / 100;
-            }
-          }
-          row.push(value);
-        }
-        results.data.push(row);
-      });
-      return results;
-    }
-  });
-  my.backends['gdocs'] = new my.BackendGDoc();
-
-}(jQuery, this.recline.Model));
-
-
\ No newline at end of file diff --git a/docs/backend/base.html b/docs/backend/base.html new file mode 100644 index 00000000..92c0ca53 --- /dev/null +++ b/docs/backend/base.html @@ -0,0 +1,37 @@ + base.js
Jump To …

base.js

Recline Backends

+ +

Backends are connectors to backend data sources and stores

+ +

This is just the base module containing various convenience methods.

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

Backbone.sync

+ +

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);
+  }

wrapInTimeout

+ +

Crude way to catch backend errors +Many of backends use JSONP and so will not get error messages and this is +a crude way to catch those errors.

  my.wrapInTimeout = function(ourFunction) {
+    var dfd = $.Deferred();
+    var timeout = 5000;
+    var timer = setTimeout(function() {
+      dfd.reject({
+        message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+      });
+    }, timeout);
+    ourFunction.done(function(arguments) {
+        clearTimeout(timer);
+        dfd.resolve(arguments);
+      })
+      .fail(function(arguments) {
+        clearTimeout(timer);
+        dfd.reject(arguments);
+      })
+      ;
+    return dfd.promise();
+  }
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/backend/dataproxy.html b/docs/backend/dataproxy.html new file mode 100644 index 00000000..83405602 --- /dev/null +++ b/docs/backend/dataproxy.html @@ -0,0 +1,87 @@ + dataproxy.js
Jump To …

dataproxy.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

DataProxy Backend

+ +

For connecting to DataProxy-s.

+ +

When initializing the DataProxy backend you can set the following attributes:

+ +
    +
  • dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com
  • +
+ +

Datasets using using this backend should set the following attributes:

+ +
    +
  • url: (required) url-of-data-to-proxy
  • +
  • format: (optional) csv | xls (defaults to csv if not specified)
  • +
+ +

Note that this is a read-only backend.

  my.DataProxy = Backbone.Model.extend({
+    defaults: {
+      dataproxy_url: 'http://jsonpdataproxy.appspot.com'
+    },
+    sync: function(method, model, options) {
+      var self = this;
+      if (method === "read") {
+        if (model.__type__ == 'Dataset') {
+          var base = self.get('dataproxy_url');

TODO: should we cache for extra efficiency

          var data = {
+            url: model.get('url')
+            , 'max-results':  1
+            , type: model.get('format') || 'csv'
+          };
+          var jqxhr = $.ajax({
+            url: base
+            , data: data
+            , dataType: 'jsonp'
+          });
+          var dfd = $.Deferred();
+          my.wrapInTimeout(jqxhr).done(function(results) {
+            model.fields.reset(_.map(results.fields, function(fieldId) {
+              return {id: fieldId};
+              })
+            );
+            dfd.resolve(model, jqxhr);
+          })
+          .fail(function(arguments) {
+            dfd.reject(arguments);
+          });
+          return dfd.promise();
+        }
+      } else {
+        alert('This backend only supports read operations');
+      }
+    },
+    query: function(dataset, queryObj) {
+      var base = this.get('dataproxy_url');
+      var data = {
+        url: dataset.get('url')
+        , 'max-results':  queryObj.size
+        , type: dataset.get('format')
+      };
+      var jqxhr = $.ajax({
+        url: base
+        , data: data
+        , dataType: 'jsonp'
+      });
+      var dfd = $.Deferred();
+      jqxhr.done(function(results) {
+        var _out = _.map(results.data, function(doc) {
+          var tmp = {};
+          _.each(results.fields, function(key, idx) {
+            tmp[key] = doc[idx];
+          });
+          return tmp;
+        });
+        dfd.resolve(_out);
+      });
+      return dfd.promise();
+    }
+  });
+  recline.Model.backends['dataproxy'] = new my.DataProxy();
+
+
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/backend/docco.css b/docs/backend/docco.css new file mode 100644 index 00000000..5aa0a8d7 --- /dev/null +++ b/docs/backend/docco.css @@ -0,0 +1,186 @@ +/*--------------------- Layout and Typography ----------------------------*/ +body { + font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; + font-size: 15px; + line-height: 22px; + color: #252519; + margin: 0; padding: 0; +} +a { + color: #261a3b; +} + a:visited { + color: #261a3b; + } +p { + margin: 0 0 15px 0; +} +h1, h2, h3, h4, h5, h6 { + margin: 0px 0 15px 0; +} + h1 { + margin-top: 40px; + } +#container { + position: relative; +} +#background { + position: fixed; + top: 0; left: 525px; right: 0; bottom: 0; + background: #f5f5ff; + border-left: 1px solid #e5e5ee; + z-index: -1; +} +#jump_to, #jump_page { + background: white; + -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; + -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; + font: 10px Arial; + text-transform: uppercase; + cursor: pointer; + text-align: right; +} +#jump_to, #jump_wrapper { + position: fixed; + right: 0; top: 0; + padding: 5px 10px; +} + #jump_wrapper { + padding: 0; + display: none; + } + #jump_to:hover #jump_wrapper { + display: block; + } + #jump_page { + padding: 5px 0 3px; + margin: 0 0 25px 25px; + } + #jump_page .source { + display: block; + padding: 5px 10px; + text-decoration: none; + border-top: 1px solid #eee; + } + #jump_page .source:hover { + background: #f5f5ff; + } + #jump_page .source:first-child { + } +table td { + border: 0; + outline: 0; +} + td.docs, th.docs { + max-width: 450px; + min-width: 450px; + min-height: 5px; + padding: 10px 25px 1px 50px; + overflow-x: hidden; + vertical-align: top; + text-align: left; + } + .docs pre { + margin: 15px 0 15px; + padding-left: 15px; + } + .docs p tt, .docs p code { + background: #f8f8ff; + border: 1px solid #dedede; + font-size: 12px; + padding: 0 0.2em; + } + .pilwrap { + position: relative; + } + .pilcrow { + font: 12px Arial; + text-decoration: none; + color: #454545; + position: absolute; + top: 3px; left: -20px; + padding: 1px 2px; + opacity: 0; + -webkit-transition: opacity 0.2s linear; + } + td.docs:hover .pilcrow { + opacity: 1; + } + td.code, th.code { + padding: 14px 15px 16px 25px; + width: 100%; + vertical-align: top; + background: #f5f5ff; + border-left: 1px solid #e5e5ee; + } + pre, tt, code { + font-size: 12px; line-height: 18px; + font-family: Monaco, Consolas, "Lucida Console", monospace; + margin: 0; padding: 0; + } + + +/*---------------------- Syntax Highlighting -----------------------------*/ +td.linenos { background-color: #f0f0f0; padding-right: 10px; } +span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } +body .hll { background-color: #ffffcc } +body .c { color: #408080; font-style: italic } /* Comment */ +body .err { border: 1px solid #FF0000 } /* Error */ +body .k { color: #954121 } /* Keyword */ +body .o { color: #666666 } /* Operator */ +body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +body .cp { color: #BC7A00 } /* Comment.Preproc */ +body .c1 { color: #408080; font-style: italic } /* Comment.Single */ +body .cs { color: #408080; font-style: italic } /* Comment.Special */ +body .gd { color: #A00000 } /* Generic.Deleted */ +body .ge { font-style: italic } /* Generic.Emph */ +body .gr { color: #FF0000 } /* Generic.Error */ +body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +body .gi { color: #00A000 } /* Generic.Inserted */ +body .go { color: #808080 } /* Generic.Output */ +body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +body .gs { font-weight: bold } /* Generic.Strong */ +body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +body .gt { color: #0040D0 } /* Generic.Traceback */ +body .kc { color: #954121 } /* Keyword.Constant */ +body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ +body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ +body .kp { color: #954121 } /* Keyword.Pseudo */ +body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ +body .kt { color: #B00040 } /* Keyword.Type */ +body .m { color: #666666 } /* Literal.Number */ +body .s { color: #219161 } /* Literal.String */ +body .na { color: #7D9029 } /* Name.Attribute */ +body .nb { color: #954121 } /* Name.Builtin */ +body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +body .no { color: #880000 } /* Name.Constant */ +body .nd { color: #AA22FF } /* Name.Decorator */ +body .ni { color: #999999; font-weight: bold } /* Name.Entity */ +body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +body .nf { color: #0000FF } /* Name.Function */ +body .nl { color: #A0A000 } /* Name.Label */ +body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +body .nt { color: #954121; font-weight: bold } /* Name.Tag */ +body .nv { color: #19469D } /* Name.Variable */ +body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +body .w { color: #bbbbbb } /* Text.Whitespace */ +body .mf { color: #666666 } /* Literal.Number.Float */ +body .mh { color: #666666 } /* Literal.Number.Hex */ +body .mi { color: #666666 } /* Literal.Number.Integer */ +body .mo { color: #666666 } /* Literal.Number.Oct */ +body .sb { color: #219161 } /* Literal.String.Backtick */ +body .sc { color: #219161 } /* Literal.String.Char */ +body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ +body .s2 { color: #219161 } /* Literal.String.Double */ +body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +body .sh { color: #219161 } /* Literal.String.Heredoc */ +body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +body .sx { color: #954121 } /* Literal.String.Other */ +body .sr { color: #BB6688 } /* Literal.String.Regex */ +body .s1 { color: #219161 } /* Literal.String.Single */ +body .ss { color: #19469D } /* Literal.String.Symbol */ +body .bp { color: #954121 } /* Name.Builtin.Pseudo */ +body .vc { color: #19469D } /* Name.Variable.Class */ +body .vg { color: #19469D } /* Name.Variable.Global */ +body .vi { color: #19469D } /* Name.Variable.Instance */ +body .il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/backend/elasticsearch.html b/docs/backend/elasticsearch.html new file mode 100644 index 00000000..d576b1cf --- /dev/null +++ b/docs/backend/elasticsearch.html @@ -0,0 +1,105 @@ + elasticsearch.js
Jump To …

elasticsearch.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

ElasticSearch Backend

+ +

Connecting to ElasticSearch.

+ +

To use this backend ensure your Dataset has one of the following +attributes (first one found is used):

+ +
+elasticsearch_url
+webstore_url
+url
+
+ +

This should point to the ES type url. E.G. for ES running on +localhost:9200 with index twitter and type tweet it would be

+ +
http://localhost:9200/twitter/tweet
  my.ElasticSearch = Backbone.Model.extend({
+    _getESUrl: function(dataset) {
+      var out = dataset.get('elasticsearch_url');
+      if (out) return out;
+      out = dataset.get('webstore_url');
+      if (out) return out;
+      out = dataset.get('url');
+      return out;
+    },
+    sync: function(method, model, options) {
+      var self = this;
+      if (method === "read") {
+        if (model.__type__ == 'Dataset') {
+          var base = self._getESUrl(model);
+          var schemaUrl = base + '/_mapping';
+          var jqxhr = $.ajax({
+            url: schemaUrl,
+            dataType: 'jsonp'
+          });
+          var dfd = $.Deferred();
+          my.wrapInTimeout(jqxhr).done(function(schema) {

only one top level key in ES = the type so we can ignore it

            var key = _.keys(schema)[0];
+            var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
+              dict.id = fieldName;
+              return dict;
+            });
+            model.fields.reset(fieldData);
+            dfd.resolve(model, jqxhr);
+          })
+          .fail(function(arguments) {
+            dfd.reject(arguments);
+          });
+          return dfd.promise();
+        }
+      } else {
+        alert('This backend currently only supports read operations');
+      }
+    },
+    _normalizeQuery: function(queryObj) {
+      if (queryObj.toJSON) {
+        var out = queryObj.toJSON();
+      } else {
+        var out = _.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;
+      }
+      return out;
+    },
+    query: function(model, queryObj) {
+      var queryNormalized = this._normalizeQuery(queryObj);
+      var data = {source: JSON.stringify(queryNormalized)};
+      var base = this._getESUrl(model);
+      var jqxhr = $.ajax({
+        url: base + '/_search',
+        data: data,
+        dataType: 'jsonp'
+      });
+      var dfd = $.Deferred();

TODO: fail case

      jqxhr.done(function(results) {
+        model.docCount = results.hits.total;
+        var docs = _.map(results.hits.hits, function(result) {
+          var _out = result._source;
+          _out.id = result._id;
+          return _out;
+        });
+        dfd.resolve(docs);
+      });
+      return dfd.promise();
+    }
+  });
+  recline.Model.backends['elasticsearch'] = new my.ElasticSearch();
+
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/backend/gdocs.html b/docs/backend/gdocs.html new file mode 100644 index 00000000..d503f679 --- /dev/null +++ b/docs/backend/gdocs.html @@ -0,0 +1,98 @@ + gdocs.js
Jump To …

gdocs.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

Google spreadsheet backend

+ +

Connect to Google Docs spreadsheet.

+ +

Dataset must have a url attribute pointing to the Gdocs +spreadsheet's JSON feed e.g.

+ +
+var dataset = new recline.Model.Dataset({
+    url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
+  },
+  'gdocs'
+);
+
  my.GDoc = Backbone.Model.extend({
+    sync: function(method, model, options) {
+      var self = this;
+      if (method === "read") { 
+        var dfd = $.Deferred(); 
+        var dataset = model;
+
+        $.getJSON(model.get('url'), function(d) {
+          result = self.gdocsToJavascript(d);
+          model.fields.reset(_.map(result.field, function(fieldId) {
+              return {id: fieldId};
+            })
+          );

cache data onto dataset (we have loaded whole gdoc it seems!)

          model._dataCache = result.data;
+          dfd.resolve(model);
+        })
+        return dfd.promise(); }
+    },
+
+    query: function(dataset, queryObj) { 
+      var dfd = $.Deferred();
+      var fields = _.pluck(dataset.fields.toJSON(), 'id');

zip the fields with the data rows to produce js objs +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]; })
+        return obj;
+      });
+      dfd.resolve(objs);
+      return dfd;
+    },
+    gdocsToJavascript:  function(gdocsSpreadsheet) {
+      /*
+         :options: (optional) optional argument dictionary:
+         columnsToUse: list of columns to use (specified by field names)
+         colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion).
+         :return: tabular data object (hash with keys: field and data).
+
+         Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.
+         */
+      var options = {};
+      if (arguments.length > 1) {
+        options = arguments[1];
+      }
+      var results = {
+        'field': [],
+        'data': []
+      };

default is no special info on type of columns

      var colTypes = {};
+      if (options.colTypes) {
+        colTypes = options.colTypes;
+      }

either extract column headings from spreadsheet directly, or used supplied ones

      if (options.columnsToUse) {

columns set to subset supplied

        results.field = options.columnsToUse;
+      } else {

set columns to use to be all available

        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);
+            }
+          }
+        }
+      }

converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])

      var rep = /^([\d\.\-]+)\%$/;
+      $.each(gdocsSpreadsheet.feed.entry, function (i, entry) {
+        var row = [];
+        for (var k in results.field) {
+          var col = results.field[k];
+          var _keyname = 'gsx$' + col;
+          var value = entry[_keyname]['$t'];

if labelled as % and value contains %, convert

          if (colTypes[col] == 'percent') {
+            if (rep.test(value)) {
+              var value2 = rep.exec(value);
+              var value3 = parseFloat(value2);
+              value = value3 / 100;
+            }
+          }
+          row.push(value);
+        }
+        results.data.push(row);
+      });
+      return results;
+    }
+  });
+  recline.Model.backends['gdocs'] = new my.GDoc();
+
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/backend/memory.html b/docs/backend/memory.html new file mode 100644 index 00000000..982be977 --- /dev/null +++ b/docs/backend/memory.html @@ -0,0 +1,98 @@ + memory.js
Jump To …

memory.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

Memory Backend - uses in-memory data

+ +

To use it you should provide in your constructor data:

+ +
    +
  • metadata (including fields array)
  • +
  • documents: list of hashes, each hash being one doc. A doc must have an id attribute which is unique.
  • +
+ +

Example:

+ +

+ // Backend setup
+ var backend = recline.Backend.Memory();
+ backend.addDataset({
+   metadata: {
+     id: 'my-id',
+     title: 'My Title'
+   },
+   fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
+   documents: [
+       {id: 0, x: 1, y: 2, z: 3},
+       {id: 1, x: 2, y: 4, z: 6}
+     ]
+ });
+ // later ...
+ var dataset = Dataset({id: 'my-id'}, 'memory');
+ dataset.fetch();
+ etc ...
+ 

  my.Memory = Backbone.Model.extend({
+    initialize: function() {
+      this.datasets = {};
+    },
+    addDataset: function(data) {
+      this.datasets[data.metadata.id] = $.extend(true, {}, data);
+    },
+    sync: function(method, model, options) {
+      var self = this;
+      if (method === "read") {
+        var dfd = $.Deferred();
+        if (model.__type__ == 'Dataset') {
+          var rawDataset = this.datasets[model.id];
+          model.set(rawDataset.metadata);
+          model.fields.reset(rawDataset.fields);
+          model.docCount = rawDataset.documents.length;
+          dfd.resolve(model);
+        }
+        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) {
+              self.datasets[model.dataset.id].documents[idx] = model.toJSON();
+            }
+          });
+          dfd.resolve(model);
+        }
+        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) {
+            return (doc.id === model.id);
+          });
+          rawDataset.documents = newdocs;
+          dfd.resolve(model);
+        }
+        return dfd.promise();
+      } else {
+        alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model);
+      }
+    },
+    query: function(model, queryObj) {
+      var numRows = queryObj.size;
+      var start = queryObj.from;
+      var dfd = $.Deferred();
+      results = this.datasets[model.id].documents;

not complete sorting!

      _.each(queryObj.sort, function(sortObj) {
+        var fieldName = _.keys(sortObj)[0];
+        results = _.sortBy(results, function(doc) {
+          var _out = doc[fieldName];
+          return (sortObj[fieldName].order == 'asc') ? _out : -1*_out;
+        });
+      });
+      var results = results.slice(start, start+numRows);
+      dfd.resolve(results);
+      return dfd.promise();
+    }
+  });
+  recline.Model.backends['memory'] = new my.Memory();
+
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/backend/webstore.html b/docs/backend/webstore.html new file mode 100644 index 00000000..aae1bbc5 --- /dev/null +++ b/docs/backend/webstore.html @@ -0,0 +1,61 @@ + webstore.js
Jump To …

webstore.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+
+(function($, my) {

Webstore Backend

+ +

Connecting to Webstores

+ +

To use this backend ensure your Dataset has a webstore_url in its attributes.

  my.Webstore = Backbone.Model.extend({
+    sync: function(method, model, options) {
+      if (method === "read") {
+        if (model.__type__ == 'Dataset') {
+          var base = model.get('webstore_url');
+          var schemaUrl = base + '/schema.json';
+          var jqxhr = $.ajax({
+            url: schemaUrl,
+              dataType: 'jsonp',
+              jsonp: '_callback'
+          });
+          var dfd = $.Deferred();
+          my.wrapInTimeout(jqxhr).done(function(schema) {
+            var fieldData = _.map(schema.data, function(item) {
+              item.id = item.name;
+              delete item.name;
+              return item;
+            });
+            model.fields.reset(fieldData);
+            model.docCount = schema.count;
+            dfd.resolve(model, jqxhr);
+          })
+          .fail(function(arguments) {
+            dfd.reject(arguments);
+          });
+          return dfd.promise();
+        }
+      }
+    },
+    query: function(model, queryObj) {
+      var base = model.get('webstore_url');
+      var data = {
+        _limit:  queryObj.size
+        , _offset: queryObj.from
+      };
+      var jqxhr = $.ajax({
+        url: base + '.json',
+        data: data,
+        dataType: 'jsonp',
+        jsonp: '_callback',
+        cache: true
+      });
+      var dfd = $.Deferred();
+      jqxhr.done(function(results) {
+        dfd.resolve(results.data);
+      });
+      return dfd.promise();
+    }
+  });
+  recline.Model.backends['webstore'] = new my.Webstore();
+
+}(jQuery, this.recline.Backend));
+
+
\ No newline at end of file diff --git a/docs/model.html b/docs/model.html index cabde51b..053d7577 100644 --- a/docs/model.html +++ b/docs/model.html @@ -1,4 +1,4 @@ - model.js
Jump To …

model.js

Recline Backbone Models

this.recline = this.recline || {};
+      model.js           
});

model.js

Recline Backbone Models

this.recline = this.recline || {};
 this.recline.Model = this.recline.Model || {};
 
 (function($, my) {

A Dataset model

@@ -34,6 +34,7 @@ updated by queryObj (if provided).

Resulting DocumentList are used to reset this.currentDocuments and are also returned.

  query: function(queryObj) {
+    this.trigger('query:start');
     var self = this;
     this.queryState.set(queryObj, {silent: true});
     var dfd = $.Deferred();
@@ -45,9 +46,11 @@ also returned.

return _doc; }); self.currentDocuments.reset(docs); + self.trigger('query:done'); dfd.resolve(self.currentDocuments); }) .fail(function(arguments) { + self.trigger('query:fail', arguments); dfd.reject(arguments); }); return dfd.promise(); @@ -94,7 +97,7 @@ just pass a single argument representing id to the ctor

A Query object storing Dataset Query state

my.Query = Backbone.Model.extend({
   defaults: {
     size: 100
-    , offset: 0
+    , from: 0
   }
 });

Backend registry

diff --git a/docs/view-flot-graph.html b/docs/view-flot-graph.html index aa18c0b8..dac85e8a 100644 --- a/docs/view-flot-graph.html +++ b/docs/view-flot-graph.html @@ -1,4 +1,4 @@ - view-flot-graph.js

view-flot-graph.js

this.recline = this.recline || {};
+      view-flot-graph.js           
'); - self.el.find('tbody').append(tr); - var newView = new my.DataTableRow({ - model: doc, - el: tr, - fields: self.fields, - }, - self.options - ); - newView.render(); - }); - this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); - return this; - } -}); -// ## DataTableRow 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. -// In addition you must pass in a fields in the constructor options. This should be list of fields for the DataTable. -// -// 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). -my.DataTableRow = Backbone.View.extend({ - initialize: function(initData, options) { + initialize: function() { _.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); + this.render(); }, - - template: ' \ - \ - {{#cells}} \ - \ - {{/cells}} \ - ', - events: { - 'click .data-table-cell-edit': 'onEditClick', - 'click .data-table-cell-editor .okButton': 'onEditorOK', - 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' + onFormSubmit: function(e) { + e.preventDefault(); + var newFrom = parseInt(this.el.find('input[name="from"]').val()); + var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom; + var query = this.el.find('.text-query').val(); + this.model.set({size: newSize, from: newFrom, q: query}); }, - - toTemplateJSON: function() { - var self = this; - var doc = this.model; - var cellData = this._fields.map(function(field) { - return { - field: field.id, - value: self._cellRenderer(doc.get(field.id), field, doc) - } - }) - return { id: this.id, cells: cellData } - }, - - render: function() { - this.el.attr('data-id', this.model.id); - var html = $.mustache(this.template, this.toTemplateJSON()); - $(this.el).html(html); - return this; - }, - - // Cell Editor - // =========== - - 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"); + onPaginationUpdate: function(e) { + e.preventDefault(); + var $el = $(e.target); + if ($el.parent().hasClass('prev')) { + var newFrom = this.model.get('from') - Math.max(0, this.model.get('size')); + } else { + var newFrom = this.model.get('from') + this.model.get('size'); } - $(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()}); + this.model.set({from: newFrom}); }, - - onEditorOK: function(e) { - var cell = $(e.target); - var rowId = cell.parents('tr').attr('data-id'); - var field = cell.parents('td').attr('data-field'); - var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); - var newData = {}; - newData[field] = newValue; - this.model.set(newData); - my.notify("Updating row...", {loader: true}); - this.model.save().then(function(response) { - my.notify("Row updated successfully", {category: 'success'}); - }) - .fail(function() { - my.notify('Error saving row', { - category: 'error', - persist: true - }); - }); - }, - - onEditorCancel: function(e) { - var cell = $(e.target).parents('.data-table-cell-value'); - cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); + render: function() { + var tmplData = this.model.toJSON(); + tmplData.to = this.model.get('from') + this.model.get('size'); + var templated = $.mustache(this.template, tmplData); + this.el.html(templated); } }); diff --git a/test/backend.elasticsearch.test.js b/test/backend.elasticsearch.test.js new file mode 100644 index 00000000..4fc931cb --- /dev/null +++ b/test/backend.elasticsearch.test.js @@ -0,0 +1,145 @@ +(function ($) { +module("Backend ElasticSearch"); + +test("ElasticSearch queryNormalize", function() { + var backend = new recline.Backend.ElasticSearch(); + var in_ = new recline.Model.Query(); + in_.set({q: ''}); + var out = backend._normalizeQuery(in_); + equal(out.q, undefined); + deepEqual(out.query.match_all, {}); + + var backend = new recline.Backend.ElasticSearch(); + var in_ = new recline.Model.Query().toJSON(); + in_.q = ''; + var out = backend._normalizeQuery(in_); + equal(out.q, undefined); + deepEqual(out.query.match_all, {}); + + var in_ = new recline.Model.Query().toJSON(); + in_.q = 'abc'; + var out = backend._normalizeQuery(in_); + equal(out.query.query_string.query, 'abc'); +}); + +var mapping_data = { + "note": { + "properties": { + "_created": { + "format": "dateOptionalTime", + "type": "date" + }, + "_last_modified": { + "format": "dateOptionalTime", + "type": "date" + }, + "end": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "start": { + "type": "string" + }, + "title": { + "type": "string" + } + } + } +}; + +var sample_data = { + "_shards": { + "failed": 0, + "successful": 5, + "total": 5 + }, + "hits": { + "hits": [ + { + "_id": "u3rpLyuFS3yLNXrtxWkMwg", + "_index": "hypernotes", + "_score": 1.0, + "_source": { + "_created": "2012-02-24T17:53:57.286Z", + "_last_modified": "2012-02-24T17:53:57.286Z", + "owner": "tester", + "title": "Note 1" + }, + "_type": "note" + }, + { + "_id": "n7JMkFOHSASJCVTXgcpqkA", + "_index": "hypernotes", + "_score": 1.0, + "_source": { + "_created": "2012-02-24T17:53:57.290Z", + "_last_modified": "2012-02-24T17:53:57.290Z", + "owner": "tester", + "title": "Note 3" + }, + "_type": "note" + }, + { + "_id": "g7UMA55gTJijvsB3dFitzw", + "_index": "hypernotes", + "_score": 1.0, + "_source": { + "_created": "2012-02-24T17:53:57.289Z", + "_last_modified": "2012-02-24T17:53:57.289Z", + "owner": "tester", + "title": "Note 2" + }, + "_type": "note" + } + ], + "max_score": 1.0, + "total": 3 + }, + "timed_out": false, + "took": 2 +}; + +test("ElasticSearch", function() { + var dataset = new recline.Model.Dataset({ + url: 'https://localhost:9200/my-es-db/my-es-type' + }, + 'elasticsearch' + ); + + var stub = sinon.stub($, 'ajax', function(options) { + if (options.url.indexOf('_mapping') != -1) { + return { + done: function(callback) { + callback(mapping_data); + return this; + }, + fail: function() { + return this; + } + } + } else { + return { + done: function(callback) { + callback(sample_data); + }, + fail: function() { + } + } + } + }); + + dataset.fetch().then(function(dataset) { + deepEqual(['_created', '_last_modified', 'end', 'owner', 'start', 'title'], _.pluck(dataset.fields.toJSON(), 'id')); + dataset.query().then(function(docList) { + equal(3, dataset.docCount); + equal(3, docList.length); + equal('Note 1', docList.models[0].get('title')); + start(); + }); + }); + $.ajax.restore(); +}); + +})(this.jQuery); diff --git a/test/backend.test.js b/test/backend.test.js index 1e9767cd..7ba2e913 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -19,7 +19,7 @@ var memoryData = { }; function makeBackendDataset() { - var backend = new recline.Model.BackendMemory(); + var backend = new recline.Backend.Memory(); backend.addDataset(memoryData); var dataset = new recline.Model.Dataset({id: memoryData.metadata.id}, backend); return dataset; @@ -44,7 +44,7 @@ test('Memory Backend: query', function () { var dataset = makeBackendDataset(); var queryObj = { size: 4 - , offset: 2 + , from: 2 }; dataset.query(queryObj).then(function(documentList) { deepEqual(data.documents[2], documentList.models[0].toJSON()); @@ -57,7 +57,7 @@ test('Memory Backend: query sort', function () { var data = dataset.backend.datasets[memoryData.metadata.id]; var queryObj = { sort: [ - ['y', 'desc'] + {'y': {order: 'desc'}} ] }; dataset.query(queryObj).then(function(docs) { diff --git a/test/index.html b/test/index.html index cd87c9af..fb96f9cf 100644 --- a/test/index.html +++ b/test/index.html @@ -18,9 +18,16 @@ - + + + + + + + + diff --git a/test/view.test.js b/test/view.test.js index f969160d..05adb60e 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -2,7 +2,7 @@ module("View"); -test('new DataTableRow View', function () { +test('new DataGridRow View', function () { var $el = $(''); $('.fixtures .test-datatable').append($el); var doc = new recline.Model.Document({ @@ -10,7 +10,7 @@ test('new DataTableRow View', function () { 'b': '2', 'a': '1' }); - var view = new recline.View.DataTableRow({ + var view = new recline.View.DataGridRow({ model: doc , el: $el , fields: new recline.Model.FieldList([{id: 'a'}, {id: 'b'}]) @@ -21,7 +21,7 @@ test('new DataTableRow View', function () { equal(tds.length, 3); equal($(tds[1]).attr('data-field'), 'a'); - var view = new recline.View.DataTableRow({ + var view = new recline.View.DataGridRow({ model: doc , el: $el , fields: new recline.Model.FieldList([{id: 'a'}, {id: 'b'}])

view-flot-graph.js

this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
 
 (function($, my) {

Graph view for a Dataset using Flot graphing library.

@@ -126,7 +126,7 @@ TODO: make this less invasive (e.g. preserve other keys in query string)

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 (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) {
    +    if ((!areWeVisible || this.model.currentDocuments.length == 0)) {
           return
         }

    create this.plot and cache it

        if (!this.plot) {

    only lines for the present

          options = {
             id: 'line',
    diff --git a/docs/view-grid.html b/docs/view-grid.html
    new file mode 100644
    index 00000000..c4c935d5
    --- /dev/null
    +++ b/docs/view-grid.html
    @@ -0,0 +1,315 @@
    +      view-grid.js           

    view-grid.js

    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({
    +  tagName:  "div",
    +  className: "data-table-container",
    +
    +  initialize: function(modelEtc, options) {
    +    var self = this;
    +    this.el = $(this.el);
    +    _.bindAll(this, 'render');
    +    this.model.currentDocuments.bind('add', this.render);
    +    this.model.currentDocuments.bind('reset', this.render);
    +    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'
    +  },

    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.state.currentColumn = $(e.target).closest('.column-header').attr('data-field');
    +    util.position('data-table-menu', e);
    +    util.render('columnActions', 'data-table-menu');
    +  },
    +
    +  onRowHeaderClick: function(e) {
    +    this.state.currentRow = $(e.target).parents('tr:first').attr('data-id');
    +    util.position('data-table-menu', e);
    +    util.render('rowActions', 'data-table-menu');
    +  },
    +  
    +  onRootHeaderClick: function(e) {
    +    util.position('data-table-menu', e);
    +    util.render('rootActions', 'data-table-menu', {'columns': this.hiddenFields});
    +  },
    +
    +  onMenuClick: function(e) {
    +    var self = this;
    +    e.preventDefault();
    +    var actions = {
    +      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: 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) },

    TODO: Delete or re-implement ...

          csv: function() { window.location.href = app.csvUrl },
    +      json: function() { window.location.href = "_rewrite/api/json" },
    +      urlImport: function() { showDialog('urlImport') },
    +      pasteImport: function() { showDialog('pasteImport') },
    +      uploadImport: function() { showDialog('uploadImport') },

    END TODO

          deleteColumn: function() {
    +        var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents.";

    TODO:

            alert('This function needs to be re-implemented');
    +        return;
    +        if (confirm(msg)) costco.deleteColumn(self.state.currentColumn);
    +      },
    +      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
    +        });
    +        doc.destroy().then(function() { 
    +            self.model.currentDocuments.remove(doc);
    +            my.notify("Row deleted successfully");
    +          })
    +          .fail(function(err) {
    +            my.notify("Errorz! " + err)
    +          })
    +      }
    +    }
    +    util.hide('data-table-menu');
    +    actions[$(e.target).attr('data-action')]();
    +  },
    +
    +  showTransformColumnDialog: function() {
    +    var $el = $('.dialog-content');
    +    util.show('dialog');
    +    var view = new my.ColumnTransform({
    +      model: this.model
    +    });
    +    view.state = this.state;
    +    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' });
    +  },
    +
    +  setColumnSort: function(order) {
    +    var sort = [{}];
    +    sort[0][this.state.currentColumn] = {order: order};
    +    this.model.query({sort: sort});
    +  },
    +  
    +  hideColumn: function() {
    +    this.hiddenFields.push(this.state.currentColumn);
    +    this.render();
    +  },
    +  
    +  showColumn: function(e) {
    +    this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column'));
    +    this.render();
    +  },

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

    + +

    Templating

      template: ' \
    +    <div class="data-table-menu-overlay" style="display: none; z-index: 101; ">&nbsp;</div> \
    +    <ul class="data-table-menu"></ul> \
    +    <table class="data-table" cellspacing="0"> \
    +      <thead> \
    +        <tr> \
    +          {{#notEmpty}} \
    +            <th class="column-header"> \
    +              <div class="column-header-title"> \
    +                <a class="root-header-menu"></a> \
    +                <span class="column-header-name"></span> \
    +              </div> \
    +            </th> \
    +          {{/notEmpty}} \
    +          {{#fields}} \
    +            <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}"> \
    +              <div class="column-header-title"> \
    +                <a class="column-header-menu"></a> \
    +                <span class="column-header-name">{{label}}</span> \
    +              </div> \
    +              </div> \
    +            </th> \
    +          {{/fields}} \
    +        </tr> \
    +      </thead> \
    +      <tbody></tbody> \
    +    </table> \
    +  ',
    +
    +  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() });
    +    return modelData;
    +  },
    +  render: function() {
    +    var self = this;
    +    this.fields = this.model.fields.filter(function(field) {
    +      return _.indexOf(self.hiddenFields, field.id) == -1;
    +    });
    +    var htmls = $.mustache(this.template, this.toTemplateJSON());
    +    this.el.html(htmls);
    +    this.model.currentDocuments.forEach(function(doc) {
    +      var tr = $('<tr />');
    +      self.el.find('tbody').append(tr);
    +      var newView = new my.DataGridRow({
    +          model: doc,
    +          el: tr,
    +          fields: self.fields,
    +        },
    +        self.options
    +        );
    +      newView.render();
    +    });
    +    this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0));
    +    return this;
    +  }
    +});

    DataGridRow 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.

    + +

    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:

    + +
    +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) {
    +    _.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);
    +  },
    +
    +  template: ' \
    +      <td><a class="row-header-menu"></a></td> \
    +      {{#cells}} \
    +      <td data-field="{{field}}"> \
    +        <div class="data-table-cell-content"> \
    +          <a href="javascript:{}" class="data-table-cell-edit" title="Edit this cell">&nbsp;</a> \
    +          <div class="data-table-cell-value">{{{value}}}</div> \
    +        </div> \
    +      </td> \
    +      {{/cells}} \
    +    ',
    +  events: {
    +    'click .data-table-cell-edit': 'onEditClick',
    +    'click .data-table-cell-editor .okButton': 'onEditorOK',
    +    'click .data-table-cell-editor .cancelButton': 'onEditorCancel'
    +  },
    +  
    +  toTemplateJSON: function() {
    +    var self = this;
    +    var doc = this.model;
    +    var cellData = this._fields.map(function(field) {
    +      return {
    +        field: field.id,
    +        value: self._cellRenderer(doc.get(field.id), field, doc)
    +      }
    +    })
    +    return { id: this.id, cells: cellData }
    +  },
    +
    +  render: function() {
    +    this.el.attr('data-id', this.model.id);
    +    var html = $.mustache(this.template, this.toTemplateJSON());
    +    $(this.el).html(html);
    +    return this;
    +  },

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

      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");
    +    }
    +    $(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()});
    +  },
    +
    +  onEditorOK: function(e) {
    +    var cell = $(e.target);
    +    var rowId = cell.parents('tr').attr('data-id');
    +    var field = cell.parents('td').attr('data-field');
    +    var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val();
    +    var newData = {};
    +    newData[field] = newValue;
    +    this.model.set(newData);
    +    my.notify("Updating row...", {loader: true});
    +    this.model.save().then(function(response) {
    +        my.notify("Row updated successfully", {category: 'success'});
    +      })
    +      .fail(function() {
    +        my.notify('Error saving row', {
    +          category: 'error',
    +          persist: true
    +        });
    +      });
    +  },
    +
    +  onEditorCancel: function(e) {
    +    var cell = $(e.target).parents('.data-table-cell-value');
    +    cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden");
    +  }
    +});
    +
    +})(jQuery, recline.View);
    +
    +
    \ No newline at end of file diff --git a/docs/view.html b/docs/view.html index 18edf6c3..3237117f 100644 --- a/docs/view.html +++ b/docs/view.html @@ -1,4 +1,4 @@ - view.js
    '); + self.el.find('tbody').append(tr); + var newView = new my.DataGridRow({ + model: doc, + el: tr, + fields: self.fields, + }, + self.options + ); + newView.render(); + }); + this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); + return this; + } +}); + +// ## DataGridRow 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. +// +// 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: +// +//
    +// 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) { + _.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); + }, + + template: ' \ + \ + {{#cells}} \ + \ + {{/cells}} \ + ', + events: { + 'click .data-table-cell-edit': 'onEditClick', + 'click .data-table-cell-editor .okButton': 'onEditorOK', + 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' + }, + + toTemplateJSON: function() { + var self = this; + var doc = this.model; + var cellData = this._fields.map(function(field) { + return { + field: field.id, + value: self._cellRenderer(doc.get(field.id), field, doc) + } + }) + return { id: this.id, cells: cellData } + }, + + render: function() { + this.el.attr('data-id', this.model.id); + var html = $.mustache(this.template, this.toTemplateJSON()); + $(this.el).html(html); + return this; + }, + + // =================== + // Cell Editor methods + 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"); + } + $(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()}); + }, + + onEditorOK: function(e) { + var cell = $(e.target); + var rowId = cell.parents('tr').attr('data-id'); + var field = cell.parents('td').attr('data-field'); + var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); + var newData = {}; + newData[field] = newValue; + this.model.set(newData); + my.notify("Updating row...", {loader: true}); + this.model.save().then(function(response) { + my.notify("Row updated successfully", {category: 'success'}); + }) + .fail(function() { + my.notify('Error saving row', { + category: 'error', + persist: true + }); + }); + }, + + onEditorCancel: function(e) { + var cell = $(e.target).parents('.data-table-cell-value'); + cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); + } +}); + +})(jQuery, recline.View); diff --git a/src/view.js b/src/view.js index 31418cdc..4aa7d252 100644 --- a/src/view.js +++ b/src/view.js @@ -23,14 +23,14 @@ this.recline.View = this.recline.View || {}; // // **views**: (optional) the views (Grid, Graph etc) for DataExplorer to // show. This is an array of view hashes. If not provided -// just initialize a DataTable with id 'grid'. Example: +// just initialize a DataGrid with id 'grid'. Example: // //
     // var views = [
     //   {
     //     id: 'grid', // used for routing
     //     label: 'Grid', // used for view switcher
    -//     view: new recline.View.DataTable({
    +//     view: new recline.View.DataGrid({
     //       model: dataset
     //     })
     //   },
    @@ -46,7 +46,6 @@ this.recline.View = this.recline.View || {};
     //
     // **config**: Config options like:
     //
    -//   * displayCount: how many documents to display initially (default: 10)
     //   * readOnly: true/false (default: false) value indicating whether to
     //     operate in read-only mode (hiding all editing options).
     //
    @@ -63,10 +62,8 @@ my.DataExplorer = Backbone.View.extend({
             
  • {{label}} \ {{/views}} \ \ - \
    \ @@ -79,16 +76,11 @@ my.DataExplorer = Backbone.View.extend({ \ ', - events: { - 'submit form.display-count': 'onDisplayCountUpdate' - }, - initialize: function(options) { var self = this; this.el = $(this.el); this.config = _.extend({ - displayCount: 50 - , readOnly: false + readOnly: false }, options.config); if (this.config.readOnly) { @@ -101,7 +93,7 @@ my.DataExplorer = Backbone.View.extend({ this.pageViews = [{ id: 'grid', label: 'Grid', - view: new my.DataTable({ + view: new my.DataGrid({ model: this.model }) }]; @@ -112,40 +104,31 @@ my.DataExplorer = Backbone.View.extend({ this.router = new Backbone.Router(); this.setupRouting(); + this.model.bind('query:start', function(eventName) { + my.notify('Loading data', {loader: true}); + }); + this.model.bind('query:done', function(eventName) { + my.clearNotifications(); + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + my.notify('Data loaded', {category: 'success'}); + }); + this.model.bind('query:fail', function(eventName, error) { + my.clearNotifications(); + my.notify(error.message, {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.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - self.query(); + self.model.query(); }) .fail(function(error) { my.notify(error.message, {category: 'error', persist: true}); }); }, - query: function() { - this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); - var queryObj = { - size: this.config.displayCount - }; - my.notify('Loading data', {loader: true}); - this.model.query(queryObj) - .done(function() { - my.clearNotifications(); - my.notify('Data loaded', {category: 'success'}); - }) - .fail(function(error) { - my.clearNotifications(); - my.notify(error.message, {category: 'error', persist: true}); - }); - }, - - onDisplayCountUpdate: function(e) { - e.preventDefault(); - this.query(); - }, - setReadOnly: function() { this.el.addClass('read-only'); }, @@ -160,6 +143,10 @@ my.DataExplorer = Backbone.View.extend({ _.each(this.pageViews, function(view, pageName) { $dataViewContainer.append(view.view.el) }); + var queryEditor = new my.QueryEditor({ + model: this.model.queryState + }); + this.el.find('.header').append(queryEditor.el); }, setupRouting: function() { @@ -190,323 +177,56 @@ my.DataExplorer = Backbone.View.extend({ } }); -// ## DataTable -// -// 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 DataTableRow for more. -my.DataTable = Backbone.View.extend({ - tagName: "div", - className: "data-table-container", - initialize: function(modelEtc, options) { - var self = this; - this.el = $(this.el); - _.bindAll(this, 'render'); - this.model.currentDocuments.bind('add', this.render); - this.model.currentDocuments.bind('reset', this.render); - 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' - }, - - // 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.state.currentColumn = $(e.target).closest('.column-header').attr('data-field'); - util.position('data-table-menu', e); - util.render('columnActions', 'data-table-menu'); - }, - - onRowHeaderClick: function(e) { - this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); - util.position('data-table-menu', e); - util.render('rowActions', 'data-table-menu'); - }, - - onRootHeaderClick: function(e) { - util.position('data-table-menu', e); - util.render('rootActions', 'data-table-menu', {'columns': this.hiddenFields}); - }, - - onMenuClick: function(e) { - var self = this; - e.preventDefault(); - var actions = { - bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: 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) }, - // TODO: Delete or re-implement ... - csv: function() { window.location.href = app.csvUrl }, - json: function() { window.location.href = "_rewrite/api/json" }, - urlImport: function() { showDialog('urlImport') }, - pasteImport: function() { showDialog('pasteImport') }, - uploadImport: function() { showDialog('uploadImport') }, - // END TODO - deleteColumn: function() { - var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents."; - // TODO: - alert('This function needs to be re-implemented'); - return; - if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); - }, - 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 - }); - doc.destroy().then(function() { - self.model.currentDocuments.remove(doc); - my.notify("Row deleted successfully"); - }) - .fail(function(err) { - my.notify("Errorz! " + err) - }) - } - } - util.hide('data-table-menu'); - actions[$(e.target).attr('data-action')](); - }, - - showTransformColumnDialog: function() { - var $el = $('.dialog-content'); - util.show('dialog'); - var view = new my.ColumnTransform({ - model: this.model - }); - view.state = this.state; - 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' }); - }, - - setColumnSort: function(order) { - this.model.query({ - sort: [ - [this.state.currentColumn, order] - ] - }); - }, - - hideColumn: function() { - this.hiddenFields.push(this.state.currentColumn); - this.render(); - }, - - showColumn: function(e) { - this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column')); - this.render(); - }, - - // ====================================================== - // #### Templating +my.QueryEditor = Backbone.View.extend({ + className: 'recline-query-editor', template: ' \ - \ -
      \ -
    • view.js

      this.recline = this.recline || {};
      +      view.js           
      _.each(this.pageViews,function(view,pageName){$dataViewContainer.append(view.view.el)}); + varqueryEditor=newmy.QueryEditor({ + model:this.model.queryState + }); + this.el.find('.header').append(queryEditor.el);},setupRouting:function(){ @@ -177,273 +164,63 @@ note this.model and dataset returned are the same

      }});} -});'); self.el.find('tbody').append(tr); - var newView = new my.DataTableRow({ + var newView = new my.DataGridRow({ model: doc, el: tr, fields: self.fields, - }); + }, + self.options + ); newView.render(); }); this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); @@ -1436,14 +869,29 @@ my.DataTable = Backbone.View.extend({ } }); -// ## DataTableRow View for rendering an individual document. +// ## DataGridRow 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. -// In addition you must pass in a fields in the constructor options. This should be list of fields for the DataTable. -my.DataTableRow = Backbone.View.extend({ - initialize: function(options) { +// In addition you must pass in a fields 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). +my.DataGridRow = Backbone.View.extend({ + initialize: function(initData, options) { _.bindAll(this, 'render'); - this._fields = options.fields; + 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); }, @@ -1454,22 +902,25 @@ my.DataTableRow = Backbone.View.extend({ \ {{/cells}} \ ', events: { 'click .data-table-cell-edit': 'onEditClick', - // cell editor 'click .data-table-cell-editor .okButton': 'onEditorOK', 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' }, toTemplateJSON: function() { + var self = this; var doc = this.model; var cellData = this._fields.map(function(field) { - return {field: field.id, value: doc.get(field.id)} + return { + field: field.id, + value: self._cellRenderer(doc.get(field.id), field, doc) + } }) return { id: this.id, cells: cellData } }, @@ -1521,6 +972,239 @@ my.DataTableRow = Backbone.View.extend({ } }); +})(jQuery, recline.View); +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { +// ## DataExplorer +// +// The primary view for the entire application. Usage: +// +//
      +// var myExplorer = new model.recline.DataExplorer({
      +//   model: {{recline.Model.Dataset instance}}
      +//   el: {{an existing dom element}}
      +//   views: {{page views}}
      +//   config: {{config options -- see below}}
      +// });
      +// 
      +// +// ### Parameters +// +// **model**: (required) Dataset instance. +// +// **el**: (required) DOM element. +// +// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to +// show. This is an array of view hashes. If not provided +// just initialize a DataGrid with id 'grid'. Example: +// +//
      +// var views = [
      +//   {
      +//     id: 'grid', // used for routing
      +//     label: 'Grid', // used for view switcher
      +//     view: new recline.View.DataGrid({
      +//       model: dataset
      +//     })
      +//   },
      +//   {
      +//     id: 'graph',
      +//     label: 'Graph',
      +//     view: new recline.View.FlotGraph({
      +//       model: dataset
      +//     })
      +//   }
      +// ];
      +// 
      +// +// **config**: Config options like: +// +// * readOnly: true/false (default: false) value indicating whether to +// 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: ' \ +
      \ +
      \ + \ +
      \ + \ +
      \ + Results found {{docCount}} \ +
      \ +
      \ +
      \ + \ + \ +
      \ + ', + + initialize: function(options) { + var self = this; + this.el = $(this.el); + this.config = _.extend({ + readOnly: false + }, + options.config); + if (this.config.readOnly) { + this.setReadOnly(); + } + // Hash of 'page' views (i.e. those for whole page) keyed by page name + if (options.views) { + this.pageViews = options.views; + } else { + this.pageViews = [{ + id: 'grid', + label: 'Grid', + view: new my.DataGrid({ + model: this.model + }) + }]; + } + // this must be called after pageViews are created + this.render(); + + this.router = new Backbone.Router(); + this.setupRouting(); + + this.model.bind('query:start', function(eventName) { + my.notify('Loading data', {loader: true}); + }); + this.model.bind('query:done', function(eventName) { + my.clearNotifications(); + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + my.notify('Data loaded', {category: 'success'}); + }); + this.model.bind('query:fail', function(eventName, error) { + my.clearNotifications(); + my.notify(error.message, {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.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + self.model.query(); + }) + .fail(function(error) { + my.notify(error.message, {category: 'error', persist: true}); + }); + }, + + setReadOnly: function() { + this.el.addClass('read-only'); + }, + + render: function() { + var tmplData = this.model.toTemplateJSON(); + tmplData.displayCount = this.config.displayCount; + tmplData.views = this.pageViews; + var template = $.mustache(this.template, tmplData); + $(this.el).html(template); + var $dataViewContainer = this.el.find('.data-view-container'); + _.each(this.pageViews, function(view, pageName) { + $dataViewContainer.append(view.view.el) + }); + var queryEditor = new my.QueryEditor({ + model: this.model.queryState + }); + this.el.find('.header').append(queryEditor.el); + }, + + setupRouting: function() { + var self = this; + // Default route + this.router.route('', this.pageViews[0].id, function() { + self.updateNav(self.pageViews[0].id); + }); + $.each(this.pageViews, function(idx, view) { + self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { + self.updateNav(viewId, queryString); + }); + }); + }, + + updateNav: function(pageName, queryString) { + this.el.find('.navigation li').removeClass('active'); + var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); + $el.parent().addClass('active'); + // show the specific page + _.each(this.pageViews, function(view, idx) { + if (view.id === pageName) { + view.view.el.show(); + } else { + view.view.el.hide(); + } + }); + } +}); + + +my.QueryEditor = Backbone.View.extend({ + className: 'recline-query-editor', + template: ' \ + \ + \ + \ + \ + \ + ', + + events: { + 'submit form': 'onFormSubmit', + 'click .action-pagination-update': 'onPaginationUpdate' + }, + + initialize: function() { + _.bindAll(this, 'render'); + this.el = $(this.el); + this.model.bind('change', this.render); + this.render(); + }, + onFormSubmit: function(e) { + e.preventDefault(); + var newFrom = parseInt(this.el.find('input[name="from"]').val()); + var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom; + var query = this.el.find('.text-query').val(); + this.model.set({size: newSize, from: newFrom, q: query}); + }, + onPaginationUpdate: function(e) { + e.preventDefault(); + var $el = $(e.target); + if ($el.parent().hasClass('prev')) { + var newFrom = this.model.get('from') - Math.max(0, this.model.get('size')); + } else { + var newFrom = this.model.get('from') + this.model.get('size'); + } + this.model.set({from: newFrom}); + }, + render: function() { + var tmplData = this.model.toJSON(); + tmplData.to = this.model.get('from') + this.model.get('size'); + var templated = $.mustache(this.template, tmplData); + this.el.html(templated); + } +}); + /* ========================================================== */ // ## Miscellaneous Utilities @@ -1828,3 +1512,508 @@ my.ColumnTransform = Backbone.View.extend({ }); })(jQuery, recline.View); +// # Recline Backends +// +// Backends are connectors to backend data sources and stores +// +// This is just the base module containing various convenience methods. +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Backbone.sync + // + // 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); + } + + // ## wrapInTimeout + // + // Crude way to catch backend errors + // Many of backends use JSONP and so will not get error messages and this is + // a crude way to catch those errors. + my.wrapInTimeout = function(ourFunction) { + var dfd = $.Deferred(); + var timeout = 5000; + var timer = setTimeout(function() { + dfd.reject({ + message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' + }); + }, timeout); + ourFunction.done(function(arguments) { + clearTimeout(timer); + dfd.resolve(arguments); + }) + .fail(function(arguments) { + clearTimeout(timer); + dfd.reject(arguments); + }) + ; + return dfd.promise(); + } +}(jQuery, this.recline.Backend)); + +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## DataProxy Backend + // + // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). + // + // When initializing the DataProxy backend you can set the following attributes: + // + // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com + // + // Datasets using using this backend should set the following attributes: + // + // * url: (required) url-of-data-to-proxy + // * format: (optional) csv | xls (defaults to csv if not specified) + // + // Note that this is a **read-only** backend. + my.DataProxy = Backbone.Model.extend({ + defaults: { + dataproxy_url: 'http://jsonpdataproxy.appspot.com' + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = self.get('dataproxy_url'); + // TODO: should we cache for extra efficiency + var data = { + url: model.get('url') + , 'max-results': 1 + , type: model.get('format') || 'csv' + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(results) { + model.fields.reset(_.map(results.fields, function(fieldId) { + return {id: fieldId}; + }) + ); + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } else { + alert('This backend only supports read operations'); + } + }, + query: function(dataset, queryObj) { + var base = this.get('dataproxy_url'); + var data = { + url: dataset.get('url') + , 'max-results': queryObj.size + , type: dataset.get('format') + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + var _out = _.map(results.data, function(doc) { + var tmp = {}; + _.each(results.fields, function(key, idx) { + tmp[key] = doc[idx]; + }); + return tmp; + }); + dfd.resolve(_out); + }); + return dfd.promise(); + } + }); + recline.Model.backends['dataproxy'] = new my.DataProxy(); + + +}(jQuery, this.recline.Backend)); +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## ElasticSearch Backend + // + // Connecting to [ElasticSearch](http://www.elasticsearch.org/) + // + // To use this backend ensure your Dataset has a elasticsearch_url, + // webstore_url or url attribute (used in that order) + my.ElasticSearch = Backbone.Model.extend({ + _getESUrl: function(dataset) { + var out = dataset.get('elasticsearch_url'); + if (out) return out; + out = dataset.get('webstore_url'); + if (out) return out; + out = dataset.get('url'); + return out; + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = self._getESUrl(model); + var schemaUrl = base + '/_mapping'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(schema) { + // only one top level key in ES = the type so we can ignore it + var key = _.keys(schema)[0]; + var fieldData = _.map(schema[key].properties, function(dict, fieldName) { + dict.id = fieldName; + return dict; + }); + model.fields.reset(fieldData); + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } else { + alert('This backend currently only supports read operations'); + } + }, + _normalizeQuery: function(queryObj) { + if (queryObj.toJSON) { + var out = queryObj.toJSON(); + } else { + var out = _.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; + } + return out; + }, + query: function(model, queryObj) { + var queryNormalized = this._normalizeQuery(queryObj); + var data = {source: JSON.stringify(queryNormalized)}; + var base = this._getESUrl(model); + var jqxhr = $.ajax({ + url: base + '/_search', + data: data, + dataType: 'jsonp' + }); + var dfd = $.Deferred(); + // TODO: fail case + jqxhr.done(function(results) { + model.docCount = results.hits.total; + var docs = _.map(results.hits.hits, function(result) { + var _out = result._source; + _out.id = result._id; + return _out; + }); + dfd.resolve(docs); + }); + return dfd.promise(); + } + }); + recline.Model.backends['elasticsearch'] = new my.ElasticSearch(); + +}(jQuery, this.recline.Backend)); + +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Google spreadsheet backend + // + // Connect to Google Docs spreadsheet. + // + // Dataset must have a url attribute pointing to the Gdocs + // spreadsheet's JSON feed e.g. + // + //
      +  // var dataset = new recline.Model.Dataset({
      +  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
      +  //   },
      +  //   'gdocs'
      +  // );
      +  // 
      + my.GDoc = Backbone.Model.extend({ + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + var dataset = model; + + $.getJSON(model.get('url'), function(d) { + result = self.gdocsToJavascript(d); + model.fields.reset(_.map(result.field, function(fieldId) { + return {id: fieldId}; + }) + ); + // cache data onto dataset (we have loaded whole gdoc it seems!) + model._dataCache = result.data; + dfd.resolve(model); + }) + return dfd.promise(); } + }, + + query: function(dataset, queryObj) { + var dfd = $.Deferred(); + var fields = _.pluck(dataset.fields.toJSON(), 'id'); + + // zip the fields with the data rows to produce js objs + // 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]; }) + return obj; + }); + dfd.resolve(objs); + return dfd; + }, + gdocsToJavascript: function(gdocsSpreadsheet) { + /* + :options: (optional) optional argument dictionary: + columnsToUse: list of columns to use (specified by field names) + colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). + :return: tabular data object (hash with keys: field and data). + + Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. + */ + var options = {}; + if (arguments.length > 1) { + options = arguments[1]; + } + var results = { + 'field': [], + 'data': [] + }; + // default is no special info on type of columns + var colTypes = {}; + if (options.colTypes) { + colTypes = options.colTypes; + } + // either extract column headings from spreadsheet directly, or used supplied ones + if (options.columnsToUse) { + // columns set to subset supplied + results.field = options.columnsToUse; + } else { + // set columns to use to be all available + 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); + } + } + } + } + + // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) + var rep = /^([\d\.\-]+)\%$/; + $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { + var row = []; + for (var k in results.field) { + var col = results.field[k]; + var _keyname = 'gsx$' + col; + var value = entry[_keyname]['$t']; + // if labelled as % and value contains %, convert + if (colTypes[col] == 'percent') { + if (rep.test(value)) { + var value2 = rep.exec(value); + var value3 = parseFloat(value2); + value = value3 / 100; + } + } + row.push(value); + } + results.data.push(row); + }); + return results; + } + }); + recline.Model.backends['gdocs'] = new my.GDoc(); + +}(jQuery, this.recline.Backend)); + +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Memory Backend - uses in-memory data + // + // This is very artificial and is really only designed for testing + // purposes. + // + // To use it you should provide in your constructor data: + // + // * metadata (including fields array) + // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. + // + // Example: + // + //
      +  //  // Backend setup
      +  //  var backend = recline.Backend.Memory();
      +  //  backend.addDataset({
      +  //    metadata: {
      +  //      id: 'my-id',
      +  //      title: 'My Title'
      +  //    },
      +  //    fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
      +  //    documents: [
      +  //        {id: 0, x: 1, y: 2, z: 3},
      +  //        {id: 1, x: 2, y: 4, z: 6}
      +  //      ]
      +  //  });
      +  //  // later ...
      +  //  var dataset = Dataset({id: 'my-id'});
      +  //  dataset.fetch();
      +  //  etc ...
      +  //  
      + my.Memory = Backbone.Model.extend({ + initialize: function() { + this.datasets = {}; + }, + addDataset: function(data) { + this.datasets[data.metadata.id] = $.extend(true, {}, data); + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + if (model.__type__ == 'Dataset') { + var rawDataset = this.datasets[model.id]; + model.set(rawDataset.metadata); + model.fields.reset(rawDataset.fields); + model.docCount = rawDataset.documents.length; + dfd.resolve(model); + } + 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) { + self.datasets[model.dataset.id].documents[idx] = model.toJSON(); + } + }); + dfd.resolve(model); + } + 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) { + return (doc.id === model.id); + }); + rawDataset.documents = newdocs; + dfd.resolve(model); + } + return dfd.promise(); + } else { + alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model); + } + }, + query: function(model, queryObj) { + var numRows = queryObj.size; + var start = queryObj.from; + var dfd = $.Deferred(); + results = this.datasets[model.id].documents; + // not complete sorting! + _.each(queryObj.sort, function(sortObj) { + var fieldName = _.keys(sortObj)[0]; + results = _.sortBy(results, function(doc) { + var _out = doc[fieldName]; + return (sortObj[fieldName].order == 'asc') ? _out : -1*_out; + }); + }); + var results = results.slice(start, start+numRows); + dfd.resolve(results); + return dfd.promise(); + } + }); + recline.Model.backends['memory'] = new my.Memory(); + +}(jQuery, this.recline.Backend)); +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Webstore Backend + // + // Connecting to [Webstores](http://github.com/okfn/webstore) + // + // To use this backend ensure your Dataset has a webstore_url in its attributes. + my.Webstore = Backbone.Model.extend({ + sync: function(method, model, options) { + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = model.get('webstore_url'); + var schemaUrl = base + '/schema.json'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp', + jsonp: '_callback' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(schema) { + var fieldData = _.map(schema.data, function(item) { + item.id = item.name; + delete item.name; + return item; + }); + model.fields.reset(fieldData); + model.docCount = schema.count; + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } + }, + query: function(model, queryObj) { + var base = model.get('webstore_url'); + var data = { + _limit: queryObj.size + , _offset: queryObj.from + }; + var jqxhr = $.ajax({ + url: base + '.json', + data: data, + dataType: 'jsonp', + jsonp: '_callback', + cache: true + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + dfd.resolve(results.data); + }); + return dfd.promise(); + } + }); + recline.Model.backends['webstore'] = new my.Webstore(); + +}(jQuery, this.recline.Backend)); diff --git a/src/backend.js b/src/backend.js deleted file mode 100644 index f723aac8..00000000 --- a/src/backend.js +++ /dev/null @@ -1,386 +0,0 @@ -// # Recline Backends -// -// Backends are connectors to backend data sources and stores -// -// Backends are implemented as Backbone models but this is just a -// convenience (they do not save or load themselves from any remote -// source) -this.recline = this.recline || {}; -this.recline.Model = this.recline.Model || {}; - -(function($, my) { - // ## Backbone.sync - // - // 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); - } - - // ## wrapInTimeout - // - // Crude way to catch backend errors - // Many of backends use JSONP and so will not get error messages and this is - // a crude way to catch those errors. - function wrapInTimeout(ourFunction) { - var dfd = $.Deferred(); - var timeout = 5000; - var timer = setTimeout(function() { - dfd.reject({ - message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' - }); - }, timeout); - ourFunction.done(function(arguments) { - clearTimeout(timer); - dfd.resolve(arguments); - }) - .fail(function(arguments) { - clearTimeout(timer); - dfd.reject(arguments); - }) - ; - return dfd.promise(); - } - - // ## BackendMemory - uses in-memory data - // - // This is very artificial and is really only designed for testing - // purposes. - // - // To use it you should provide in your constructor data: - // - // * metadata (including fields array) - // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. - // - // Example: - // - //
      -  //  // Backend setup
      -  //  var backend = Backend();
      -  //  backend.addDataset({
      -  //    metadata: {
      -  //      id: 'my-id',
      -  //      title: 'My Title'
      -  //    },
      -  //    fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
      -  //    documents: [
      -  //        {id: 0, x: 1, y: 2, z: 3},
      -  //        {id: 1, x: 2, y: 4, z: 6}
      -  //      ]
      -  //  });
      -  //  // later ...
      -  //  var dataset = Dataset({id: 'my-id'});
      -  //  dataset.fetch();
      -  //  etc ...
      -  //  
      - my.BackendMemory = Backbone.Model.extend({ - initialize: function() { - this.datasets = {}; - }, - addDataset: function(data) { - this.datasets[data.metadata.id] = $.extend(true, {}, data); - }, - sync: function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - if (model.__type__ == 'Dataset') { - var rawDataset = this.datasets[model.id]; - model.set(rawDataset.metadata); - model.fields.reset(rawDataset.fields); - model.docCount = rawDataset.documents.length; - dfd.resolve(model); - } - 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) { - self.datasets[model.dataset.id].documents[idx] = model.toJSON(); - } - }); - dfd.resolve(model); - } - 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) { - return (doc.id === model.id); - }); - rawDataset.documents = newdocs; - dfd.resolve(model); - } - return dfd.promise(); - } else { - alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); - } - }, - query: function(model, queryObj) { - var numRows = queryObj.size; - var start = queryObj.offset; - var dfd = $.Deferred(); - results = this.datasets[model.id].documents; - // not complete sorting! - _.each(queryObj.sort, function(item) { - results = _.sortBy(results, function(doc) { - var _out = doc[item[0]]; - return (item[1] == 'asc') ? _out : -1*_out; - }); - }); - var results = results.slice(start, start+numRows); - dfd.resolve(results); - return dfd.promise(); - } - }); - my.backends['memory'] = new my.BackendMemory(); - - // ## BackendWebstore - // - // Connecting to [Webstores](http://github.com/okfn/webstore) - // - // To use this backend ensure your Dataset has a webstore_url in its attributes. - my.BackendWebstore = Backbone.Model.extend({ - sync: function(method, model, options) { - if (method === "read") { - if (model.__type__ == 'Dataset') { - var base = model.get('webstore_url'); - var schemaUrl = base + '/schema.json'; - var jqxhr = $.ajax({ - url: schemaUrl, - dataType: 'jsonp', - jsonp: '_callback' - }); - var dfd = $.Deferred(); - wrapInTimeout(jqxhr).done(function(schema) { - var fieldData = _.map(schema.data, function(item) { - item.id = item.name; - delete item.name; - return item; - }); - model.fields.reset(fieldData); - model.docCount = schema.count; - dfd.resolve(model, jqxhr); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } - } - }, - query: function(model, queryObj) { - var base = model.get('webstore_url'); - var data = { - _limit: queryObj.size - , _offset: queryObj.offset - }; - var jqxhr = $.ajax({ - url: base + '.json', - data: data, - dataType: 'jsonp', - jsonp: '_callback', - cache: true - }); - var dfd = $.Deferred(); - jqxhr.done(function(results) { - dfd.resolve(results.data); - }); - return dfd.promise(); - } - }); - my.backends['webstore'] = new my.BackendWebstore(); - - // ## BackendDataProxy - // - // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). - // - // When initializing the DataProxy backend you can set the following attributes: - // - // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com - // - // Datasets using using this backend should set the following attributes: - // - // * url: (required) url-of-data-to-proxy - // * format: (optional) csv | xls (defaults to csv if not specified) - // - // Note that this is a **read-only** backend. - my.BackendDataProxy = Backbone.Model.extend({ - defaults: { - dataproxy_url: 'http://jsonpdataproxy.appspot.com' - }, - sync: function(method, model, options) { - var self = this; - if (method === "read") { - if (model.__type__ == 'Dataset') { - var base = self.get('dataproxy_url'); - // TODO: should we cache for extra efficiency - var data = { - url: model.get('url') - , 'max-results': 1 - , type: model.get('format') || 'csv' - }; - var jqxhr = $.ajax({ - url: base - , data: data - , dataType: 'jsonp' - }); - var dfd = $.Deferred(); - wrapInTimeout(jqxhr).done(function(results) { - model.fields.reset(_.map(results.fields, function(fieldId) { - return {id: fieldId}; - }) - ); - dfd.resolve(model, jqxhr); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } - } else { - alert('This backend only supports read operations'); - } - }, - query: function(dataset, queryObj) { - var base = this.get('dataproxy_url'); - var data = { - url: dataset.get('url') - , 'max-results': queryObj.size - , type: dataset.get('format') - }; - var jqxhr = $.ajax({ - url: base - , data: data - , dataType: 'jsonp' - }); - var dfd = $.Deferred(); - jqxhr.done(function(results) { - var _out = _.map(results.data, function(doc) { - var tmp = {}; - _.each(results.fields, function(key, idx) { - tmp[key] = doc[idx]; - }); - return tmp; - }); - dfd.resolve(_out); - }); - return dfd.promise(); - } - }); - my.backends['dataproxy'] = new my.BackendDataProxy(); - - - // ## Google spreadsheet backend - // - // Connect to Google Docs spreadsheet. - // - // Dataset must have a url attribute pointing to the Gdocs - // spreadsheet's JSON feed e.g. - // - //
      -  // var dataset = new recline.Model.Dataset({
      -  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
      -  //   },
      -  //   'gdocs'
      -  // );
      -  // 
      - my.BackendGDoc = Backbone.Model.extend({ - sync: function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - var dataset = model; - - $.getJSON(model.get('url'), function(d) { - result = self.gdocsToJavascript(d); - model.fields.reset(_.map(result.field, function(fieldId) { - return {id: fieldId}; - }) - ); - // cache data onto dataset (we have loaded whole gdoc it seems!) - model._dataCache = result.data; - dfd.resolve(model); - }) - return dfd.promise(); } - }, - - query: function(dataset, queryObj) { - var dfd = $.Deferred(); - var fields = _.pluck(dataset.fields.toJSON(), 'id'); - - // zip the fields with the data rows to produce js objs - // 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]; }) - return obj; - }); - dfd.resolve(objs); - return dfd; - }, - gdocsToJavascript: function(gdocsSpreadsheet) { - /* - :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by field names) - colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). - :return: tabular data object (hash with keys: field and data). - - Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. - */ - var options = {}; - if (arguments.length > 1) { - options = arguments[1]; - } - var results = { - 'field': [], - 'data': [] - }; - // default is no special info on type of columns - var colTypes = {}; - if (options.colTypes) { - colTypes = options.colTypes; - } - // either extract column headings from spreadsheet directly, or used supplied ones - if (options.columnsToUse) { - // columns set to subset supplied - results.field = options.columnsToUse; - } else { - // set columns to use to be all available - 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); - } - } - } - } - - // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) - var rep = /^([\d\.\-]+)\%$/; - $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { - var row = []; - for (var k in results.field) { - var col = results.field[k]; - var _keyname = 'gsx$' + col; - var value = entry[_keyname]['$t']; - // if labelled as % and value contains %, convert - if (colTypes[col] == 'percent') { - if (rep.test(value)) { - var value2 = rep.exec(value); - var value3 = parseFloat(value2); - value = value3 / 100; - } - } - row.push(value); - } - results.data.push(row); - }); - return results; - } - }); - my.backends['gdocs'] = new my.BackendGDoc(); - -}(jQuery, this.recline.Model)); diff --git a/src/backend/base.js b/src/backend/base.js new file mode 100644 index 00000000..caf317f9 --- /dev/null +++ b/src/backend/base.js @@ -0,0 +1,42 @@ +// # Recline Backends +// +// Backends are connectors to backend data sources and stores +// +// This is just the base module containing various convenience methods. +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Backbone.sync + // + // 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); + } + + // ## wrapInTimeout + // + // Crude way to catch backend errors + // Many of backends use JSONP and so will not get error messages and this is + // a crude way to catch those errors. + my.wrapInTimeout = function(ourFunction) { + var dfd = $.Deferred(); + var timeout = 5000; + var timer = setTimeout(function() { + dfd.reject({ + message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' + }); + }, timeout); + ourFunction.done(function(arguments) { + clearTimeout(timer); + dfd.resolve(arguments); + }) + .fail(function(arguments) { + clearTimeout(timer); + dfd.reject(arguments); + }) + ; + return dfd.promise(); + } +}(jQuery, this.recline.Backend)); + diff --git a/src/backend/dataproxy.js b/src/backend/dataproxy.js new file mode 100644 index 00000000..60cf5482 --- /dev/null +++ b/src/backend/dataproxy.js @@ -0,0 +1,85 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## DataProxy Backend + // + // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). + // + // When initializing the DataProxy backend you can set the following attributes: + // + // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com + // + // Datasets using using this backend should set the following attributes: + // + // * url: (required) url-of-data-to-proxy + // * format: (optional) csv | xls (defaults to csv if not specified) + // + // Note that this is a **read-only** backend. + my.DataProxy = Backbone.Model.extend({ + defaults: { + dataproxy_url: 'http://jsonpdataproxy.appspot.com' + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = self.get('dataproxy_url'); + // TODO: should we cache for extra efficiency + var data = { + url: model.get('url') + , 'max-results': 1 + , type: model.get('format') || 'csv' + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(results) { + model.fields.reset(_.map(results.fields, function(fieldId) { + return {id: fieldId}; + }) + ); + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } else { + alert('This backend only supports read operations'); + } + }, + query: function(dataset, queryObj) { + var base = this.get('dataproxy_url'); + var data = { + url: dataset.get('url') + , 'max-results': queryObj.size + , type: dataset.get('format') + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + var _out = _.map(results.data, function(doc) { + var tmp = {}; + _.each(results.fields, function(key, idx) { + tmp[key] = doc[idx]; + }); + return tmp; + }); + dfd.resolve(_out); + }); + return dfd.promise(); + } + }); + recline.Model.backends['dataproxy'] = new my.DataProxy(); + + +}(jQuery, this.recline.Backend)); diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js new file mode 100644 index 00000000..78e27af6 --- /dev/null +++ b/src/backend/elasticsearch.js @@ -0,0 +1,110 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## ElasticSearch Backend + // + // Connecting to [ElasticSearch](http://www.elasticsearch.org/). + // + // To use this backend ensure your Dataset has one of the following + // attributes (first one found is used): + // + //
      +  // elasticsearch_url
      +  // webstore_url
      +  // url
      +  // 
      + // + // This should point to the ES type url. E.G. for ES running on + // localhost:9200 with index twitter and type tweet it would be + // + //
      http://localhost:9200/twitter/tweet
      + my.ElasticSearch = Backbone.Model.extend({ + _getESUrl: function(dataset) { + var out = dataset.get('elasticsearch_url'); + if (out) return out; + out = dataset.get('webstore_url'); + if (out) return out; + out = dataset.get('url'); + return out; + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = self._getESUrl(model); + var schemaUrl = base + '/_mapping'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(schema) { + // only one top level key in ES = the type so we can ignore it + var key = _.keys(schema)[0]; + var fieldData = _.map(schema[key].properties, function(dict, fieldName) { + dict.id = fieldName; + return dict; + }); + model.fields.reset(fieldData); + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } else { + alert('This backend currently only supports read operations'); + } + }, + _normalizeQuery: function(queryObj) { + if (queryObj.toJSON) { + var out = queryObj.toJSON(); + } else { + var out = _.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; + } + return out; + }, + query: function(model, queryObj) { + var queryNormalized = this._normalizeQuery(queryObj); + var data = {source: JSON.stringify(queryNormalized)}; + var base = this._getESUrl(model); + var jqxhr = $.ajax({ + url: base + '/_search', + data: data, + dataType: 'jsonp' + }); + var dfd = $.Deferred(); + // TODO: fail case + jqxhr.done(function(results) { + model.docCount = results.hits.total; + var docs = _.map(results.hits.hits, function(result) { + var _out = result._source; + _out.id = result._id; + return _out; + }); + dfd.resolve(docs); + }); + return dfd.promise(); + } + }); + recline.Model.backends['elasticsearch'] = new my.ElasticSearch(); + +}(jQuery, this.recline.Backend)); + diff --git a/src/backend/gdocs.js b/src/backend/gdocs.js new file mode 100644 index 00000000..9436fd51 --- /dev/null +++ b/src/backend/gdocs.js @@ -0,0 +1,117 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Google spreadsheet backend + // + // Connect to Google Docs spreadsheet. + // + // Dataset must have a url attribute pointing to the Gdocs + // spreadsheet's JSON feed e.g. + // + //
      +  // var dataset = new recline.Model.Dataset({
      +  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
      +  //   },
      +  //   'gdocs'
      +  // );
      +  // 
      + my.GDoc = Backbone.Model.extend({ + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + var dataset = model; + + $.getJSON(model.get('url'), function(d) { + result = self.gdocsToJavascript(d); + model.fields.reset(_.map(result.field, function(fieldId) { + return {id: fieldId}; + }) + ); + // cache data onto dataset (we have loaded whole gdoc it seems!) + model._dataCache = result.data; + dfd.resolve(model); + }) + return dfd.promise(); } + }, + + query: function(dataset, queryObj) { + var dfd = $.Deferred(); + var fields = _.pluck(dataset.fields.toJSON(), 'id'); + + // zip the fields with the data rows to produce js objs + // 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]; }) + return obj; + }); + dfd.resolve(objs); + return dfd; + }, + gdocsToJavascript: function(gdocsSpreadsheet) { + /* + :options: (optional) optional argument dictionary: + columnsToUse: list of columns to use (specified by field names) + colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). + :return: tabular data object (hash with keys: field and data). + + Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. + */ + var options = {}; + if (arguments.length > 1) { + options = arguments[1]; + } + var results = { + 'field': [], + 'data': [] + }; + // default is no special info on type of columns + var colTypes = {}; + if (options.colTypes) { + colTypes = options.colTypes; + } + // either extract column headings from spreadsheet directly, or used supplied ones + if (options.columnsToUse) { + // columns set to subset supplied + results.field = options.columnsToUse; + } else { + // set columns to use to be all available + 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); + } + } + } + } + + // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) + var rep = /^([\d\.\-]+)\%$/; + $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { + var row = []; + for (var k in results.field) { + var col = results.field[k]; + var _keyname = 'gsx$' + col; + var value = entry[_keyname]['$t']; + // if labelled as % and value contains %, convert + if (colTypes[col] == 'percent') { + if (rep.test(value)) { + var value2 = rep.exec(value); + var value3 = parseFloat(value2); + value = value3 / 100; + } + } + row.push(value); + } + results.data.push(row); + }); + return results; + } + }); + recline.Model.backends['gdocs'] = new my.GDoc(); + +}(jQuery, this.recline.Backend)); + diff --git a/src/backend/memory.js b/src/backend/memory.js new file mode 100644 index 00000000..6da45b6b --- /dev/null +++ b/src/backend/memory.js @@ -0,0 +1,98 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Memory Backend - uses in-memory data + // + // To use it you should provide in your constructor data: + // + // * metadata (including fields array) + // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. + // + // Example: + // + //
      +  //  // Backend setup
      +  //  var backend = recline.Backend.Memory();
      +  //  backend.addDataset({
      +  //    metadata: {
      +  //      id: 'my-id',
      +  //      title: 'My Title'
      +  //    },
      +  //    fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
      +  //    documents: [
      +  //        {id: 0, x: 1, y: 2, z: 3},
      +  //        {id: 1, x: 2, y: 4, z: 6}
      +  //      ]
      +  //  });
      +  //  // later ...
      +  //  var dataset = Dataset({id: 'my-id'}, 'memory');
      +  //  dataset.fetch();
      +  //  etc ...
      +  //  
      + my.Memory = Backbone.Model.extend({ + initialize: function() { + this.datasets = {}; + }, + addDataset: function(data) { + this.datasets[data.metadata.id] = $.extend(true, {}, data); + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + if (model.__type__ == 'Dataset') { + var rawDataset = this.datasets[model.id]; + model.set(rawDataset.metadata); + model.fields.reset(rawDataset.fields); + model.docCount = rawDataset.documents.length; + dfd.resolve(model); + } + 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) { + self.datasets[model.dataset.id].documents[idx] = model.toJSON(); + } + }); + dfd.resolve(model); + } + 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) { + return (doc.id === model.id); + }); + rawDataset.documents = newdocs; + dfd.resolve(model); + } + return dfd.promise(); + } else { + alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model); + } + }, + query: function(model, queryObj) { + var numRows = queryObj.size; + var start = queryObj.from; + var dfd = $.Deferred(); + results = this.datasets[model.id].documents; + // not complete sorting! + _.each(queryObj.sort, function(sortObj) { + var fieldName = _.keys(sortObj)[0]; + results = _.sortBy(results, function(doc) { + var _out = doc[fieldName]; + return (sortObj[fieldName].order == 'asc') ? _out : -1*_out; + }); + }); + var results = results.slice(start, start+numRows); + dfd.resolve(results); + return dfd.promise(); + } + }); + recline.Model.backends['memory'] = new my.Memory(); + +}(jQuery, this.recline.Backend)); diff --git a/src/backend/webstore.js b/src/backend/webstore.js new file mode 100644 index 00000000..b08bfdfa --- /dev/null +++ b/src/backend/webstore.js @@ -0,0 +1,61 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; + +(function($, my) { + // ## Webstore Backend + // + // Connecting to [Webstores](http://github.com/okfn/webstore) + // + // To use this backend ensure your Dataset has a webstore_url in its attributes. + my.Webstore = Backbone.Model.extend({ + sync: function(method, model, options) { + if (method === "read") { + if (model.__type__ == 'Dataset') { + var base = model.get('webstore_url'); + var schemaUrl = base + '/schema.json'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp', + jsonp: '_callback' + }); + var dfd = $.Deferred(); + my.wrapInTimeout(jqxhr).done(function(schema) { + var fieldData = _.map(schema.data, function(item) { + item.id = item.name; + delete item.name; + return item; + }); + model.fields.reset(fieldData); + model.docCount = schema.count; + dfd.resolve(model, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } + }, + query: function(model, queryObj) { + var base = model.get('webstore_url'); + var data = { + _limit: queryObj.size + , _offset: queryObj.from + }; + var jqxhr = $.ajax({ + url: base + '.json', + data: data, + dataType: 'jsonp', + jsonp: '_callback', + cache: true + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + dfd.resolve(results.data); + }); + return dfd.promise(); + } + }); + recline.Model.backends['webstore'] = new my.Webstore(); + +}(jQuery, this.recline.Backend)); diff --git a/src/model.js b/src/model.js index 932902d9..b4b78594 100644 --- a/src/model.js +++ b/src/model.js @@ -39,6 +39,7 @@ my.Dataset = Backbone.Model.extend({ // Resulting DocumentList are used to reset this.currentDocuments and are // also returned. query: function(queryObj) { + this.trigger('query:start'); var self = this; this.queryState.set(queryObj, {silent: true}); var dfd = $.Deferred(); @@ -50,9 +51,11 @@ my.Dataset = Backbone.Model.extend({ return _doc; }); self.currentDocuments.reset(docs); + self.trigger('query:done'); dfd.resolve(self.currentDocuments); }) .fail(function(arguments) { + self.trigger('query:fail', arguments); dfd.reject(arguments); }); return dfd.promise(); @@ -113,7 +116,7 @@ my.FieldList = Backbone.Collection.extend({ my.Query = Backbone.Model.extend({ defaults: { size: 100 - , offset: 0 + , from: 0 } }); diff --git a/src/view-flot-graph.js b/src/view-flot-graph.js index a27179ba..5bd1afe5 100644 --- a/src/view-flot-graph.js +++ b/src/view-flot-graph.js @@ -137,7 +137,7 @@ 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 (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { + if ((!areWeVisible || this.model.currentDocuments.length == 0)) { return } // create this.plot and cache it diff --git a/src/view-grid.js b/src/view-grid.js new file mode 100644 index 00000000..c079226b --- /dev/null +++ b/src/view-grid.js @@ -0,0 +1,336 @@ +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({ + tagName: "div", + className: "data-table-container", + + initialize: function(modelEtc, options) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); + 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' + }, + + // 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.state.currentColumn = $(e.target).closest('.column-header').attr('data-field'); + util.position('data-table-menu', e); + util.render('columnActions', 'data-table-menu'); + }, + + onRowHeaderClick: function(e) { + this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); + util.position('data-table-menu', e); + util.render('rowActions', 'data-table-menu'); + }, + + onRootHeaderClick: function(e) { + util.position('data-table-menu', e); + util.render('rootActions', 'data-table-menu', {'columns': this.hiddenFields}); + }, + + onMenuClick: function(e) { + var self = this; + e.preventDefault(); + var actions = { + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: 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) }, + // TODO: Delete or re-implement ... + csv: function() { window.location.href = app.csvUrl }, + json: function() { window.location.href = "_rewrite/api/json" }, + urlImport: function() { showDialog('urlImport') }, + pasteImport: function() { showDialog('pasteImport') }, + uploadImport: function() { showDialog('uploadImport') }, + // END TODO + deleteColumn: function() { + var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents."; + // TODO: + alert('This function needs to be re-implemented'); + return; + if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); + }, + 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 + }); + doc.destroy().then(function() { + self.model.currentDocuments.remove(doc); + my.notify("Row deleted successfully"); + }) + .fail(function(err) { + my.notify("Errorz! " + err) + }) + } + } + util.hide('data-table-menu'); + actions[$(e.target).attr('data-action')](); + }, + + showTransformColumnDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.ColumnTransform({ + model: this.model + }); + view.state = this.state; + 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' }); + }, + + setColumnSort: function(order) { + var sort = [{}]; + sort[0][this.state.currentColumn] = {order: order}; + this.model.query({sort: sort}); + }, + + hideColumn: function() { + this.hiddenFields.push(this.state.currentColumn); + this.render(); + }, + + showColumn: function(e) { + this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column')); + this.render(); + }, + + // ====================================================== + // #### Templating + template: ' \ + \ +
        \ +

        view.js

        this.recline = this.recline || {};
         this.recline.View = this.recline.View || {};
         
         (function($, my) {

        DataExplorer

        @@ -22,14 +22,14 @@ var myExplorer = new model.recline.DataExplorer({

        views: (optional) the views (Grid, Graph etc) for DataExplorer to show. This is an array of view hashes. If not provided -just initialize a DataTable with id 'grid'. Example:

        +just initialize a DataGrid with id 'grid'. Example:

         var views = [
           {
             id: 'grid', // used for routing
             label: 'Grid', // used for view switcher
        -    view: new recline.View.DataTable({
        +    view: new recline.View.DataGrid({
               model: dataset
             })
           },
        @@ -46,7 +46,6 @@ var views = [
         

        config: Config options like:

          -
        • displayCount: how many documents to display initially (default: 10)
        • readOnly: true/false (default: false) value indicating whether to operate in read-only mode (hiding all editing options).
        @@ -63,10 +62,8 @@ FlotGraph subview.

        <li><a href="#{{id}}" class="btn">{{label}}</a> \ {{/views}} \ </ul> \ - <div class="pagination"> \ - <form class="display-count"> \ - Showing 0 to <input name="displayCount" type="text" value="{{displayCount}}" title="Edit and hit enter to change the number of rows displayed" /> of <span class="doc-count">{{docCount}}</span> \ - </form> \ + <div class="recline-results-info"> \ + Results found <span class="doc-count">{{docCount}}</span> \ </div> \ </div> \ <div class="data-view-container"></div> \ @@ -79,16 +76,11 @@ FlotGraph subview.

        </div> \ ', - events: { - 'submit form.display-count': 'onDisplayCountUpdate' - }, - initialize: function(options) { var self = this; this.el = $(this.el); this.config = _.extend({ - displayCount: 50 - , readOnly: false + readOnly: false }, options.config); if (this.config.readOnly) { @@ -99,46 +91,37 @@ FlotGraph subview.

        this.pageViews = [{ id: 'grid', label: 'Grid', - view: new my.DataTable({ + view: new my.DataGrid({ model: this.model }) }]; }

        this must be called after pageViews are created

            this.render();
         
             this.router = new Backbone.Router();
        -    this.setupRouting();

        retrieve basic data like fields etc + this.setupRouting(); + + this.model.bind('query:start', function(eventName) { + my.notify('Loading data', {loader: true}); + }); + this.model.bind('query:done', function(eventName) { + my.clearNotifications(); + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + my.notify('Data loaded', {category: 'success'}); + }); + this.model.bind('query:fail', function(eventName, error) { + my.clearNotifications(); + my.notify(error.message, {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.el.find('.doc-count').text(self.model.docCount || 'Unknown');
        -        self.query();
        +        self.model.query();
               })
               .fail(function(error) {
                 my.notify(error.message, {category: 'error', persist: true});
               });
           },
         
        -  query: function() {
        -    this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val());
        -    var queryObj = {
        -      size: this.config.displayCount
        -    };
        -    my.notify('Loading data', {loader: true});
        -    this.model.query(queryObj)
        -      .done(function() {
        -        my.clearNotifications();
        -        my.notify('Data loaded', {category: 'success'});
        -      })
        -      .fail(function(error) {
        -        my.clearNotifications();
        -        my.notify(error.message, {category: 'error', persist: true});
        -      });
        -  },
        -
        -  onDisplayCountUpdate: function(e) {
        -    e.preventDefault();
        -    this.query();
        -  },
        -
           setReadOnly: function() {
             this.el.addClass('read-only');
           },
        @@ -153,6 +136,10 @@ note this.model and dataset returned are the same

        DataTable

        +}); -

        Provides a tabular view on a Dataset.

        -

        Initialize it with a recline.Dataset object.

        my.DataTable = Backbone.View.extend({
        -  tagName:  "div",
        -  className: "data-table-container",
        -
        -  initialize: function() {
        -    var self = this;
        -    this.el = $(this.el);
        -    _.bindAll(this, 'render');
        -    this.model.currentDocuments.bind('add', this.render);
        -    this.model.currentDocuments.bind('reset', this.render);
        -    this.model.currentDocuments.bind('remove', this.render);
        -    this.state = {};
        -    this.hiddenFields = [];
        -  },
        -
        -  events: {
        -    '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)). -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.state.currentColumn = $(e.target).closest('.column-header').attr('data-field');
        -    util.position('data-table-menu', e);
        -    util.render('columnActions', 'data-table-menu');
        -  },
        -
        -  onRowHeaderClick: function(e) {
        -    this.state.currentRow = $(e.target).parents('tr:first').attr('data-id');
        -    util.position('data-table-menu', e);
        -    util.render('rowActions', 'data-table-menu');
        -  },
        -  
        -  onRootHeaderClick: function(e) {
        -    util.position('data-table-menu', e);
        -    util.render('rootActions', 'data-table-menu', {'columns': this.hiddenFields});
        -  },
        -
        -  onMenuClick: function(e) {
        -    var self = this;
        -    e.preventDefault();
        -    var actions = {
        -      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: 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) },

        TODO: Delete or re-implement ...

              csv: function() { window.location.href = app.csvUrl },
        -      json: function() { window.location.href = "_rewrite/api/json" },
        -      urlImport: function() { showDialog('urlImport') },
        -      pasteImport: function() { showDialog('pasteImport') },
        -      uploadImport: function() { showDialog('uploadImport') },

        END TODO

              deleteColumn: function() {
        -        var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents.";

        TODO:

                alert('This function needs to be re-implemented');
        -        return;
        -        if (confirm(msg)) costco.deleteColumn(self.state.currentColumn);
        -      },
        -      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
        -        });
        -        doc.destroy().then(function() { 
        -            self.model.currentDocuments.remove(doc);
        -            my.notify("Row deleted successfully");
        -          })
        -          .fail(function(err) {
        -            my.notify("Errorz! " + err)
        -          })
        -      }
        -    }
        -    util.hide('data-table-menu');
        -    actions[$(e.target).attr('data-action')]();
        -  },
        -
        -  showTransformColumnDialog: function() {
        -    var $el = $('.dialog-content');
        -    util.show('dialog');
        -    var view = new my.ColumnTransform({
        -      model: this.model
        -    });
        -    view.state = this.state;
        -    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' });
        -  },
        -
        -  setColumnSort: function(order) {
        -    this.model.query({
        -      sort: [
        -        [this.state.currentColumn, order]
        -      ]
        -    });
        -  },
        -  
        -  hideColumn: function() {
        -    this.hiddenFields.push(this.state.currentColumn);
        -    this.render();
        -  },
        -  
        -  showColumn: function(e) {
        -    this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column'));
        -    this.render();
        -  },

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

        - -

        Templating

          template: ' \
        -    <div class="data-table-menu-overlay" style="display: none; z-index: 101; ">&nbsp;</div> \
        -    <ul class="data-table-menu"></ul> \
        -    <table class="data-table" cellspacing="0"> \
        -      <thead> \
        -        <tr> \
        -          {{#notEmpty}} \
        -            <th class="column-header"> \
        -              <div class="column-header-title"> \
        -                <a class="root-header-menu"></a> \
        -                <span class="column-header-name"></span> \
        -              </div> \
        -            </th> \
        -          {{/notEmpty}} \
        -          {{#fields}} \
        -            <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}"> \
        -              <div class="column-header-title"> \
        -                <a class="column-header-menu"></a> \
        -                <span class="column-header-name">{{label}}</span> \
        -              </div> \
        -              </div> \
        -            </th> \
        -          {{/fields}} \
        -        </tr> \
        -      </thead> \
        -      <tbody></tbody> \
        -    </table> \
        +my.QueryEditor = Backbone.View.extend({
        +  className: 'recline-query-editor', 
        +  template: ' \
        +    <form action="" method="GET"> \
        +      <input type="text" name="q" value="{{q}}" class="text-query" /> \
        +      <div class="pagination"> \
        +        <ul> \
        +          <li class="prev action-pagination-update"><a>&laquo; back</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>next &raquo;</a></li> \
        +        </ul> \
        +      </div> \
        +      <button type="submit" class="btn" style="">Update &raquo;</button> \
        +    </form> \
           ',
         
        -  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() });
        -    return modelData;
        +  events: {
        +    'submit form': 'onFormSubmit',
        +    'click .action-pagination-update': 'onPaginationUpdate'
           },
        -  render: function() {
        -    var self = this;
        -    this.fields = this.model.fields.filter(function(field) {
        -      return _.indexOf(self.hiddenFields, field.id) == -1;
        -    });
        -    var htmls = $.mustache(this.template, this.toTemplateJSON());
        -    this.el.html(htmls);
        -    this.model.currentDocuments.forEach(function(doc) {
        -      var tr = $('<tr />');
        -      self.el.find('tbody').append(tr);
        -      var newView = new my.DataTableRow({
        -          model: doc,
        -          el: tr,
        -          fields: self.fields,
        -        });
        -      newView.render();
        -    });
        -    this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0));
        -    return this;
        -  }
        -});

        DataTableRow 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. -In addition you must pass in a fields in the constructor options. This should be list of fields for the DataTable.

        my.DataTableRow = Backbone.View.extend({
        -  initialize: function(options) {
        +  initialize: function() {
             _.bindAll(this, 'render');
        -    this._fields = options.fields;
             this.el = $(this.el);
             this.model.bind('change', this.render);
        +    this.render();
           },
        -
        -  template: ' \
        -      <td><a class="row-header-menu"></a></td> \
        -      {{#cells}} \
        -      <td data-field="{{field}}"> \
        -        <div class="data-table-cell-content"> \
        -          <a href="javascript:{}" class="data-table-cell-edit" title="Edit this cell">&nbsp;</a> \
        -          <div class="data-table-cell-value">{{value}}</div> \
        -        </div> \
        -      </td> \
        -      {{/cells}} \
        -    ',
        -  events: {
        -    'click .data-table-cell-edit': 'onEditClick',

        cell editor

            'click .data-table-cell-editor .okButton': 'onEditorOK',
        -    'click .data-table-cell-editor .cancelButton': 'onEditorCancel'
        +  onFormSubmit: function(e) {
        +    e.preventDefault();
        +    var newFrom = parseInt(this.el.find('input[name="from"]').val());
        +    var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom;
        +    var query = this.el.find('.text-query').val();
        +    this.model.set({size: newSize, from: newFrom, q: query});
           },
        -  
        -  toTemplateJSON: function() {
        -    var doc = this.model;
        -    var cellData = this._fields.map(function(field) {
        -      return {field: field.id, value: doc.get(field.id)}
        -    })
        -    return { id: this.id, cells: cellData }
        -  },
        -
        -  render: function() {
        -    this.el.attr('data-id', this.model.id);
        -    var html = $.mustache(this.template, this.toTemplateJSON());
        -    $(this.el).html(html);
        -    return this;
        -  },

        Cell Editor

          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");
        +  onPaginationUpdate: function(e) {
        +    e.preventDefault();
        +    var $el = $(e.target);
        +    if ($el.parent().hasClass('prev')) {
        +      var newFrom = this.model.get('from') - Math.max(0, this.model.get('size'));
        +    } else {
        +      var newFrom = this.model.get('from') + this.model.get('size');
             }
        -    $(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()});
        +    this.model.set({from: newFrom});
           },
        -
        -  onEditorOK: function(e) {
        -    var cell = $(e.target);
        -    var rowId = cell.parents('tr').attr('data-id');
        -    var field = cell.parents('td').attr('data-field');
        -    var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val();
        -    var newData = {};
        -    newData[field] = newValue;
        -    this.model.set(newData);
        -    my.notify("Updating row...", {loader: true});
        -    this.model.save().then(function(response) {
        -        my.notify("Row updated successfully", {category: 'success'});
        -      })
        -      .fail(function() {
        -        my.notify('Error saving row', {
        -          category: 'error',
        -          persist: true
        -        });
        -      });
        -  },
        -
        -  onEditorCancel: function(e) {
        -    var cell = $(e.target).parents('.data-table-cell-value');
        -    cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden");
        +  render: function() {
        +    var tmplData = this.model.toJSON();
        +    tmplData.to = this.model.get('from') + this.model.get('size');
        +    var templated = $.mustache(this.template, tmplData);
        +    this.el.html(templated);
           }
         });
         
         
        -/* ========================================================== */

        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 {};
        @@ -453,7 +230,7 @@ In addition you must pass in a fields in the constructor options. This should be
               query: parsed[2] || ''
             }
           }
        -}

        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) {
           var urlParams = {},
             e, d = function (s) {
               return unescape(s.replace(/\+/g, " "));
        @@ -463,13 +240,13 @@ In addition you must pass in a fields in the constructor options. This should be
           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]);
        +  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) {
        @@ -481,7 +258,7 @@ In addition you must pass in a fields in the constructor options. This should be
         
         my.setHashQueryString = function(queryParams) {
           window.location.hash = window.location.hash.split('?')[0] + my.composeQueryString(queryParams);
        -}

        notify

        +}

        notify

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

        @@ -513,7 +290,7 @@ In addition you must pass in a fields in the constructor options. This should be }); }, 1000); } -}

        clearNotifications

        +}

        clearNotifications

        Clear all existing notifications

        my.clearNotifications = function() {
           var $notifications = $('.data-explorer .alert-message');
        diff --git a/index.html b/index.html
        index a69f2f88..0c18e8ed 100644
        --- a/index.html
        +++ b/index.html
        @@ -79,9 +79,9 @@
               
      • Pure javascript (no Flash) and designed for integration -- so it is easy to embed in other sites and applications
      • Open-source
      • -
      • Built on Backbone - so - robust design and extremely easy to exend
      • +
      • Built on the simple but powerful Backbone giving a + clean and robust design which is easy to extend
      • Properly designed model with clean separation of data and presentation
      • Componentized design means you use only what you need
      • @@ -105,16 +105,21 @@

        Documentation

        Recline has a simple structure layered on top of the basic Model/View - distinction inherent in Backbone. There are the following three domain objects (all Backbone Models):

        + distinction inherent in Backbone. There are the following two main domain objects (all Backbone Models):

        • Dataset: represents the dataset. Holds dataset info and a pointer to list of data items (Documents in our terminology) which it can load from the relevant Backend.
        • Document: an individual data item (e.g. a row from a relational database or a spreadsheet, a document from from a document DB like CouchDB or MongoDB).
        • -
        • Backend: provides a way to get data from a specific 'Backend' data source. They provide methods for loading and saving Datasets and individuals Documents as well as for bulk loading via a query API and doing bulk transforms on the backend
        -

        There are then various Views (you can easily write your own). Each view holds a pointer to a Dataset:

        + +

        Backends (more info below) then connect Dataset and Documents to data + from a specific 'Backend' data source. They provide methods for loading and + saving Datasets and individuals Documents as well as for bulk loading via a + query API and doing bulk transforms on the backend.

        + +

        Complementing the model are various Views (you can easily write your own). Each view holds a pointer to a Dataset:

        • DataExplorer: the parent view which manages the overall app and sets up sub views.
        • -
        • DataTable: the data grid / table view.
        • +
        • DataGrid: the data grid view.
        • FlotGraph: a simple graphing view using Flot.
        @@ -143,13 +148,60 @@ Backbone.history.start(); href="demo/">Demo -- just hit view source (NB: the javascript for the demo is in: app.js).

        +

        Backends

        + +

        Backends are connectors to backend data sources from which data can be retrieved.

        + +

        Backends are implemented as Backbone models but this is just a convenience +(they do not save or load themselves from any remote source). You can see +detailed examples of backend implementation in the source documentation +below.

        + +

        A backend must implement two methods:

        +
        +sync(method, model, options)
        +query(dataset, queryObj)
        +
        + +

        sync(method, model, options)

        + +

        This is an implemntation of Backbone.sync and is used to override +Backbone.sync on operations for Datasets and Documents which are using this +backend.

        + +

        For read-only implementations you will need only to implement read method +for Dataset models (and even this can be a null operation). The read method +should return relevant metadata for the Dataset. We do not require read support +for Documents because they are loaded in bulk by the query method.

        + +

        For backends supporting write operations you must implement update and +delete support for Document objects.

        + +

        All code paths should return an object conforming to the jquery promise +API.

        + +

        query(dataset, queryObj)

        + +

        Query the backend for documents returning them in bulk. This method will be +used by the Dataset.query method to search the backend for documents, +retrieving the results in bulk. This method should also set the docCount +attribute on the dataset.

        + +

        queryObj should be either a recline.Model.Query object or a +Hash. The structure of data in the Query object or Hash should follow that +defined in issue 34. (That said, if you are writing your own backend and have +control over the query object you can obviously use whatever structure you +like).

        Source Docs (via Docco)

        Tests

        diff --git a/make b/make new file mode 100755 index 00000000..9177a082 --- /dev/null +++ b/make @@ -0,0 +1,15 @@ +#!/bin/bash +echo "** Combining js files" +cat src/*.js src/backend/*.js > recline.js + +# build docs +echo "** Building docs" +docco src/model.js src/view.js src/view-grid.js src/view-flot-graph.js +mkdir -p /tmp/recline-docs +mkdir -p docs/backend +PWD=`pwd` +FILES=$PWD/src/backend/*.js +DEST=$PWD/docs/backend +cd /tmp/recline-docs && docco $FILES && mv docs/* $DEST +echo "** Docs built ok" + diff --git a/package.json b/package.json new file mode 100644 index 00000000..be14f8df --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name" : "recline", + "description" : "Data explorer library and app in pure Javascript", + "url" : "http://okfnlabs.org/recline", + "keywords" : ["data", "explorer", "grid", "table", "library", "app"], + "author" : "Rufus Pollock and Max Ogden ", + "contributors" : [], + "dependencies" : { + "jquery" : ">=1.6", + "underscore" : ">=1.0", + "backbone" : ">=0.5", + "jquery.mustache" : "" + }, + "lib" : "src", + "main" : "recline.js", + "version" : "0.3a" +} diff --git a/recline.js b/recline.js index c52f5f53..ce3a2046 100644 --- a/recline.js +++ b/recline.js @@ -1,389 +1,3 @@ -// # Recline Backends -// -// Backends are connectors to backend data sources and stores -// -// Backends are implemented as Backbone models but this is just a -// convenience (they do not save or load themselves from any remote -// source) -this.recline = this.recline || {}; -this.recline.Model = this.recline.Model || {}; - -(function($, my) { - // ## Backbone.sync - // - // 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); - } - - // ## wrapInTimeout - // - // Crude way to catch backend errors - // Many of backends use JSONP and so will not get error messages and this is - // a crude way to catch those errors. - function wrapInTimeout(ourFunction) { - var dfd = $.Deferred(); - var timeout = 5000; - var timer = setTimeout(function() { - dfd.reject({ - message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' - }); - }, timeout); - ourFunction.done(function(arguments) { - clearTimeout(timer); - dfd.resolve(arguments); - }) - .fail(function(arguments) { - clearTimeout(timer); - dfd.reject(arguments); - }) - ; - return dfd.promise(); - } - - // ## BackendMemory - uses in-memory data - // - // This is very artificial and is really only designed for testing - // purposes. - // - // To use it you should provide in your constructor data: - // - // * metadata (including fields array) - // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. - // - // Example: - // - //
        -  //  // Backend setup
        -  //  var backend = Backend();
        -  //  backend.addDataset({
        -  //    metadata: {
        -  //      id: 'my-id',
        -  //      title: 'My Title'
        -  //    },
        -  //    fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
        -  //    documents: [
        -  //        {id: 0, x: 1, y: 2, z: 3},
        -  //        {id: 1, x: 2, y: 4, z: 6}
        -  //      ]
        -  //  });
        -  //  // later ...
        -  //  var dataset = Dataset({id: 'my-id'});
        -  //  dataset.fetch();
        -  //  etc ...
        -  //  
        - my.BackendMemory = Backbone.Model.extend({ - initialize: function() { - this.datasets = {}; - }, - addDataset: function(data) { - this.datasets[data.metadata.id] = $.extend(true, {}, data); - }, - sync: function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - if (model.__type__ == 'Dataset') { - var rawDataset = this.datasets[model.id]; - model.set(rawDataset.metadata); - model.fields.reset(rawDataset.fields); - model.docCount = rawDataset.documents.length; - dfd.resolve(model); - } - 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) { - self.datasets[model.dataset.id].documents[idx] = model.toJSON(); - } - }); - dfd.resolve(model); - } - 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) { - return (doc.id === model.id); - }); - rawDataset.documents = newdocs; - dfd.resolve(model); - } - return dfd.promise(); - } else { - alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); - } - }, - query: function(model, queryObj) { - var numRows = queryObj.size; - var start = queryObj.offset; - var dfd = $.Deferred(); - results = this.datasets[model.id].documents; - // not complete sorting! - _.each(queryObj.sort, function(item) { - results = _.sortBy(results, function(doc) { - var _out = doc[item[0]]; - return (item[1] == 'asc') ? _out : -1*_out; - }); - }); - var results = results.slice(start, start+numRows); - dfd.resolve(results); - return dfd.promise(); - } - }); - my.backends['memory'] = new my.BackendMemory(); - - // ## BackendWebstore - // - // Connecting to [Webstores](http://github.com/okfn/webstore) - // - // To use this backend ensure your Dataset has a webstore_url in its attributes. - my.BackendWebstore = Backbone.Model.extend({ - sync: function(method, model, options) { - if (method === "read") { - if (model.__type__ == 'Dataset') { - var base = model.get('webstore_url'); - var schemaUrl = base + '/schema.json'; - var jqxhr = $.ajax({ - url: schemaUrl, - dataType: 'jsonp', - jsonp: '_callback' - }); - var dfd = $.Deferred(); - wrapInTimeout(jqxhr).done(function(schema) { - var fieldData = _.map(schema.data, function(item) { - item.id = item.name; - delete item.name; - return item; - }); - model.fields.reset(fieldData); - model.docCount = schema.count; - dfd.resolve(model, jqxhr); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } - } - }, - query: function(model, queryObj) { - var base = model.get('webstore_url'); - var data = { - _limit: queryObj.size - , _offset: queryObj.offset - }; - var jqxhr = $.ajax({ - url: base + '.json', - data: data, - dataType: 'jsonp', - jsonp: '_callback', - cache: true - }); - var dfd = $.Deferred(); - jqxhr.done(function(results) { - dfd.resolve(results.data); - }); - return dfd.promise(); - } - }); - my.backends['webstore'] = new my.BackendWebstore(); - - // ## BackendDataProxy - // - // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). - // - // When initializing the DataProxy backend you can set the following attributes: - // - // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com - // - // Datasets using using this backend should set the following attributes: - // - // * url: (required) url-of-data-to-proxy - // * format: (optional) csv | xls (defaults to csv if not specified) - // - // Note that this is a **read-only** backend. - my.BackendDataProxy = Backbone.Model.extend({ - defaults: { - dataproxy_url: 'http://jsonpdataproxy.appspot.com' - }, - sync: function(method, model, options) { - var self = this; - if (method === "read") { - if (model.__type__ == 'Dataset') { - var base = self.get('dataproxy_url'); - // TODO: should we cache for extra efficiency - var data = { - url: model.get('url') - , 'max-results': 1 - , type: model.get('format') || 'csv' - }; - var jqxhr = $.ajax({ - url: base - , data: data - , dataType: 'jsonp' - }); - var dfd = $.Deferred(); - wrapInTimeout(jqxhr).done(function(results) { - model.fields.reset(_.map(results.fields, function(fieldId) { - return {id: fieldId}; - }) - ); - dfd.resolve(model, jqxhr); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } - } else { - alert('This backend only supports read operations'); - } - }, - query: function(dataset, queryObj) { - var base = this.get('dataproxy_url'); - var data = { - url: dataset.get('url') - , 'max-results': queryObj.size - , type: dataset.get('format') - }; - var jqxhr = $.ajax({ - url: base - , data: data - , dataType: 'jsonp' - }); - var dfd = $.Deferred(); - jqxhr.done(function(results) { - var _out = _.map(results.data, function(doc) { - var tmp = {}; - _.each(results.fields, function(key, idx) { - tmp[key] = doc[idx]; - }); - return tmp; - }); - dfd.resolve(_out); - }); - return dfd.promise(); - } - }); - my.backends['dataproxy'] = new my.BackendDataProxy(); - - - // ## Google spreadsheet backend - // - // Connect to Google Docs spreadsheet. - // - // Dataset must have a url attribute pointing to the Gdocs - // spreadsheet's JSON feed e.g. - // - //
        -  // var dataset = new recline.Model.Dataset({
        -  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
        -  //   },
        -  //   'gdocs'
        -  // );
        -  // 
        - my.BackendGDoc = Backbone.Model.extend({ - sync: function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - var dataset = model; - - $.getJSON(model.get('url'), function(d) { - result = self.gdocsToJavascript(d); - model.fields.reset(_.map(result.field, function(fieldId) { - return {id: fieldId}; - }) - ); - // cache data onto dataset (we have loaded whole gdoc it seems!) - model._dataCache = result.data; - dfd.resolve(model); - }) - return dfd.promise(); } - }, - - query: function(dataset, queryObj) { - var dfd = $.Deferred(); - var fields = _.pluck(dataset.fields.toJSON(), 'id'); - - // zip the fields with the data rows to produce js objs - // 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]; }) - return obj; - }); - dfd.resolve(objs); - return dfd; - }, - gdocsToJavascript: function(gdocsSpreadsheet) { - /* - :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by field names) - colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). - :return: tabular data object (hash with keys: field and data). - - Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. - */ - var options = {}; - if (arguments.length > 1) { - options = arguments[1]; - } - var results = { - 'field': [], - 'data': [] - }; - // default is no special info on type of columns - var colTypes = {}; - if (options.colTypes) { - colTypes = options.colTypes; - } - // either extract column headings from spreadsheet directly, or used supplied ones - if (options.columnsToUse) { - // columns set to subset supplied - results.field = options.columnsToUse; - } else { - // set columns to use to be all available - 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); - } - } - } - } - - // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) - var rep = /^([\d\.\-]+)\%$/; - $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { - var row = []; - for (var k in results.field) { - var col = results.field[k]; - var _keyname = 'gsx$' + col; - var value = entry[_keyname]['$t']; - // if labelled as % and value contains %, convert - if (colTypes[col] == 'percent') { - if (rep.test(value)) { - var value2 = rep.exec(value); - var value3 = parseFloat(value2); - value = value3 / 100; - } - } - row.push(value); - } - results.data.push(row); - }); - return results; - } - }); - my.backends['gdocs'] = new my.BackendGDoc(); - -}(jQuery, this.recline.Model)); // importScripts('lib/underscore.js'); onmessage = function(message) { @@ -550,6 +164,7 @@ my.Dataset = Backbone.Model.extend({ // Resulting DocumentList are used to reset this.currentDocuments and are // also returned. query: function(queryObj) { + this.trigger('query:start'); var self = this; this.queryState.set(queryObj, {silent: true}); var dfd = $.Deferred(); @@ -561,9 +176,11 @@ my.Dataset = Backbone.Model.extend({ return _doc; }); self.currentDocuments.reset(docs); + self.trigger('query:done'); dfd.resolve(self.currentDocuments); }) .fail(function(arguments) { + self.trigger('query:fail', arguments); dfd.reject(arguments); }); return dfd.promise(); @@ -624,7 +241,7 @@ my.FieldList = Backbone.Collection.extend({ my.Query = Backbone.Model.extend({ defaults: { size: 100 - , offset: 0 + , from: 0 } }); @@ -800,10 +417,9 @@ var util = function() { this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; -// Views module following classic module pattern (function($, my) { -// Graph view for a Dataset using Flot graphing library. +// ## Graph view for a Dataset using Flot graphing library. // // Initialization arguments: // @@ -937,7 +553,7 @@ 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 (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { + if ((!areWeVisible || this.model.currentDocuments.length == 0)) { return } // create this.plot and cache it @@ -1038,204 +654,20 @@ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { -// ## DataExplorer -// -// The primary view for the entire application. Usage: -// -//
        -// var myExplorer = new model.recline.DataExplorer({
        -//   model: {{recline.Model.Dataset instance}}
        -//   el: {{an existing dom element}}
        -//   views: {{page views}}
        -//   config: {{config options -- see below}}
        -// });
        -// 
        -// -// ### Parameters -// -// **model**: (required) Dataset instance. -// -// **el**: (required) DOM element. -// -// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to -// show. This is an array of view hashes. If not provided -// just initialize a DataTable with id 'grid'. Example: -// -//
        -// var views = [
        -//   {
        -//     id: 'grid', // used for routing
        -//     label: 'Grid', // used for view switcher
        -//     view: new recline.View.DataTable({
        -//       model: dataset
        -//     })
        -//   },
        -//   {
        -//     id: 'graph',
        -//     label: 'Graph',
        -//     view: new recline.View.FlotGraph({
        -//       model: dataset
        -//     })
        -//   }
        -// ];
        -// 
        -// -// **config**: Config options like: -// -// * displayCount: how many documents to display initially (default: 10) -// * readOnly: true/false (default: false) value indicating whether to -// 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: ' \ -
        \ -
        \ - \ -
        \ - \ - \ -
        \ -
        \ - \ - \ -
        \ - ', - - events: { - 'submit form.display-count': 'onDisplayCountUpdate' - }, - - initialize: function(options) { - var self = this; - this.el = $(this.el); - this.config = _.extend({ - displayCount: 50 - , readOnly: false - }, - options.config); - if (this.config.readOnly) { - this.setReadOnly(); - } - // Hash of 'page' views (i.e. those for whole page) keyed by page name - if (options.views) { - this.pageViews = options.views; - } else { - this.pageViews = [{ - id: 'grid', - label: 'Grid', - view: new my.DataTable({ - model: this.model - }) - }]; - } - // this must be called after pageViews are created - this.render(); - - this.router = new Backbone.Router(); - this.setupRouting(); - - // retrieve basic data like fields etc - // note this.model and dataset returned are the same - this.model.fetch() - .done(function(dataset) { - self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - self.query(); - }) - .fail(function(error) { - my.notify(error.message, {category: 'error', persist: true}); - }); - }, - - query: function() { - this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); - var queryObj = { - size: this.config.displayCount - }; - my.notify('Loading data', {loader: true}); - this.model.query(queryObj) - .done(function() { - my.clearNotifications(); - my.notify('Data loaded', {category: 'success'}); - }) - .fail(function(error) { - my.clearNotifications(); - my.notify(error.message, {category: 'error', persist: true}); - }); - }, - - onDisplayCountUpdate: function(e) { - e.preventDefault(); - this.query(); - }, - - setReadOnly: function() { - this.el.addClass('read-only'); - }, - - render: function() { - var tmplData = this.model.toTemplateJSON(); - tmplData.displayCount = this.config.displayCount; - tmplData.views = this.pageViews; - var template = $.mustache(this.template, tmplData); - $(this.el).html(template); - var $dataViewContainer = this.el.find('.data-view-container'); - _.each(this.pageViews, function(view, pageName) { - $dataViewContainer.append(view.view.el) - }); - }, - - setupRouting: function() { - var self = this; - // Default route - this.router.route('', this.pageViews[0].id, function() { - self.updateNav(self.pageViews[0].id); - }); - $.each(this.pageViews, function(idx, view) { - self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { - self.updateNav(viewId, queryString); - }); - }); - }, - - updateNav: function(pageName, queryString) { - this.el.find('.navigation li').removeClass('active'); - var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); - $el.parent().addClass('active'); - // show the specific page - _.each(this.pageViews, function(view, idx) { - if (view.id === pageName) { - view.view.el.show(); - } else { - view.view.el.hide(); - } - }); - } -}); - -// ## DataTable +// ## DataGrid // // Provides a tabular view on a Dataset. // // Initialize it with a recline.Dataset object. -my.DataTable = Backbone.View.extend({ +// +// Additional options passed in second arguments. Options: +// +// * cellRenderer: function used to render individual cells. See DataGridRow for more. +my.DataGrid = Backbone.View.extend({ tagName: "div", className: "data-table-container", - initialize: function() { + initialize: function(modelEtc, options) { var self = this; this.el = $(this.el); _.bindAll(this, 'render'); @@ -1244,6 +676,7 @@ my.DataTable = Backbone.View.extend({ this.model.currentDocuments.bind('remove', this.render); this.state = {}; this.hiddenFields = []; + this.options = options; }, events: { @@ -1359,11 +792,9 @@ my.DataTable = Backbone.View.extend({ }, setColumnSort: function(order) { - this.model.query({ - sort: [ - [this.state.currentColumn, order] - ] - }); + var sort = [{}]; + sort[0][this.state.currentColumn] = {order: order}; + this.model.query({sort: sort}); }, hideColumn: function() { @@ -1424,11 +855,13 @@ my.DataTable = Backbone.View.extend({ this.model.currentDocuments.forEach(function(doc) { var tr = $('
        \
        \   \ -
        {{value}}
        \ +
        {{{value}}}
        \
        \
        \ + \ + \ + {{#notEmpty}} \ + \ + {{/notEmpty}} \ + {{#fields}} \ + \ + {{/fields}} \ + \ + \ + \ +
        \ +
        \ + \ + \ +
        \ +
        \ +
        \ + \ + {{label}} \ +
        \ + \ +
        \ + ', + + 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() }); + return modelData; + }, + render: function() { + var self = this; + this.fields = this.model.fields.filter(function(field) { + return _.indexOf(self.hiddenFields, field.id) == -1; + }); + var htmls = $.mustache(this.template, this.toTemplateJSON()); + this.el.html(htmls); + this.model.currentDocuments.forEach(function(doc) { + var tr = $('
        \ +
        \ +   \ +
        {{{value}}}
        \ +
        \ +
        \ - \ - \ - {{#notEmpty}} \ - \ - {{/notEmpty}} \ - {{#fields}} \ - \ - {{/fields}} \ - \ - \ - \ -
        \ -
        \ - \ - \ -
        \ -
        \ -
        \ - \ - {{label}} \ -
        \ - \ -
        \ +
        \ + \ + \ + \ +
        \ ', - 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() }); - return modelData; + events: { + 'submit form': 'onFormSubmit', + 'click .action-pagination-update': 'onPaginationUpdate' }, - render: function() { - var self = this; - this.fields = this.model.fields.filter(function(field) { - return _.indexOf(self.hiddenFields, field.id) == -1; - }); - var htmls = $.mustache(this.template, this.toTemplateJSON()); - this.el.html(htmls); - this.model.currentDocuments.forEach(function(doc) { - var tr = $('
        \ -
        \ -   \ -
        {{{value}}}
        \ -
        \ -