diff --git a/README.md b/README.md index 6db0a2e9..ed2bc918 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ toolkit, all in pure javascript and html. Designed for standalone use or as a library to integrate into your own app. -Live demo: http://okfnlabs.org/recline/demo/ +

Recline Website - including Overview, Documentation, Demos etc

## Features @@ -15,21 +15,16 @@ Live demo: http://okfnlabs.org/recline/demo/ * View and edit your data in clean tabular interface * Bulk update/clean your data using an easy scripting UI * Visualize your data +* And more ... see ![screenshot](http://farm8.staticflickr.com/7020/6847468031_0f474de5f7_b.jpg) -## Demo App - -Open demo/index.html in your favourite browser. - - ## Developer Notes Running the tests by opening `test/index.html` in your browser. - ## Copyright and License Copyright 2011 Max Ogden and Rufus Pollock. diff --git a/demo/index.html b/demo/index.html index 48a0d5e0..c941752a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,8 +28,6 @@ - - diff --git a/demo/js/app.js b/demo/js/app.js index cbfeddc2..d0360492 100755 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -2,9 +2,26 @@ $(function() { var $el = $('
'); $el.appendTo($('.data-explorer-here')); var dataset = demoDataset(); + var views = [ + { + id: 'grid', + label: 'Grid', + view: new recline.View.DataTable({ + model: dataset + }) + }, + { + id: 'graph', + label: 'Graph', + view: new recline.View.FlotGraph({ + model: dataset + }) + } + ]; window.dataExplorer = new recline.View.DataExplorer({ el: $el , model: dataset + , views: views }); Backbone.history.start(); setupLoadFromWebstore(function(dataset) { @@ -34,8 +51,8 @@ function demoDataset() { title: 'My Test Dataset' , name: '1-my-test-dataset' , id: datasetId - , headers: ['x', 'y', 'z'] }, + fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}], documents: [ {id: 0, x: 1, y: 2, z: 3} , {id: 1, x: 2, y: 4, z: 6} diff --git a/docs/backend.html b/docs/backend.html index e49f5bbe..b1f5009e 100644 --- a/docs/backend.html +++ b/docs/backend.html @@ -1,4 +1,4 @@ - backend.js

backend.js

Recline Backends

+ backend.js

backend.js

Recline Backends

Backends are connectors to backend data sources and stores

@@ -41,7 +41,7 @@ purposes.

To use it you should provide in your constructor data:

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

    Example:

    @@ -52,9 +52,9 @@ var backend = Backend(); backend.addDataset({ metadata: { id: 'my-id', - title: 'My Title', - headers: ['x', 'y', 'z'], + 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} @@ -79,6 +79,7 @@ etc ... 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); } @@ -141,12 +142,12 @@ etc ... }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(schema) { - headers = _.map(schema.data, function(item) { - return item.name; - }); - model.set({ - headers: headers + 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); }) @@ -214,9 +215,10 @@ etc ... }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(results) { - model.set({ - headers: results.fields - }); + model.fields.reset(_.map(results.fields, function(fieldId) { + return {id: fieldId}; + }) + ); dfd.resolve(model, jqxhr); }) .fail(function(arguments) { @@ -276,7 +278,10 @@ var dataset = new recline.Model.Dataset({ $.getJSON(model.get('url'), function(d) { result = self.gdocsToJavascript(d); - model.set({'headers': result.header});

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

          model._dataCache = result.data;
+          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(); }
@@ -284,7 +289,7 @@ var dataset = new recline.Model.Dataset({
 
     query: function(dataset, queryObj) { 
       var dfd = $.Deferred();
-      var fields = dataset.get('headers');

zip the field headers with the data rows to produce js objs + 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]; })
@@ -296,9 +301,9 @@ TODO: factor this out as a common method with other backends

gdocsToJavascript: function(gdocsSpreadsheet) { /* :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by header names) + 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: header and data). + :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. */ @@ -307,25 +312,25 @@ TODO: factor this out as a common method with other backends

options = arguments[1]; } var results = { - 'header': [], + '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.header = options.columnsToUse;
+      }

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.header.push(col);
+                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.header) {
-          var col = results.header[k];
+        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)) {
diff --git a/docs/model.html b/docs/model.html
index f6fee7df..cabde51b 100644
--- a/docs/model.html
+++ b/docs/model.html
@@ -1,72 +1,104 @@
-      model.js           

model.js

Recline Backbone Models

this.recline = this.recline || {};
+      model.js           
'); + 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) { + _.bindAll(this, 'render'); + this._fields = options.fields; + this.el = $(this.el); + this.model.bind('change', this.render); + }, + + template: ' \ + \ + {{#cells}} \ + \ + {{/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 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"); + } + $(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"); + } +}); + + +/* ========================================================== */ +// ## Miscellaneous Utilities + +var urlPathRegex = /^([^?]+)(\?.*)?/; + +// Parse the Hash section of a URL into path and query string +my.parseHashUrl = function(hashUrl) { + var parsed = urlPathRegex.exec(hashUrl); + if (parsed == null) { + return {}; + } else { + return { + path: parsed[1], + query: parsed[2] || '' + } + } +} // Parse a URL query string (?xyz=abc...) into a dictionary. -function parseQueryString(q) { +my.parseQueryString = function(q) { var urlParams = {}, e, d = function (s) { return unescape(s.replace(/\+/g, " ")); @@ -22,6 +522,27 @@ function parseQueryString(q) { return urlParams; } +// Parse the query string out of the URL hash +my.parseHashQueryString = function() { + q = my.parseHashUrl(window.location.hash).query; + return my.parseQueryString(q); +} + +// Compse a Query String +my.composeQueryString = function(queryParams) { + var queryString = '?'; + var items = []; + $.each(queryParams, function(key, value) { + items.push(key + '=' + JSON.stringify(value)); + }); + queryString += items.join('&'); + return queryString; +} + +my.setHashQueryString = function(queryParams) { + window.location.hash = window.location.hash.split('?')[0] + my.composeQueryString(queryParams); +} + // ## notify // // Create a notification (a div.alert-message in div.alert-messsages) using provide messages and options. Options are: diff --git a/test/backend.test.js b/test/backend.test.js new file mode 100644 index 00000000..1e9767cd --- /dev/null +++ b/test/backend.test.js @@ -0,0 +1,507 @@ +(function ($) { +module("Backend"); + +var memoryData = { + metadata: { + title: 'My Test Dataset' + , name: '1-my-test-dataset' + , id: 'test-dataset' + }, + fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}], + documents: [ + {id: 0, x: 1, y: 2, z: 3} + , {id: 1, x: 2, y: 4, z: 6} + , {id: 2, x: 3, y: 6, z: 9} + , {id: 3, x: 4, y: 8, z: 12} + , {id: 4, x: 5, y: 10, z: 15} + , {id: 5, x: 6, y: 12, z: 18} + ] +}; + +function makeBackendDataset() { + var backend = new recline.Model.BackendMemory(); + backend.addDataset(memoryData); + var dataset = new recline.Model.Dataset({id: memoryData.metadata.id}, backend); + return dataset; +} + +test('Memory Backend: basics', function () { + var dataset = makeBackendDataset(); + expect(3); + // convenience for tests - get the data that should get changed + var data = dataset.backend.datasets[memoryData.metadata.id]; + dataset.fetch().then(function(datasetAgain) { + equal(dataset.get('name'), data.metadata.name); + deepEqual(_.pluck(dataset.fields.toJSON(), 'id'), _.pluck(data.fields, 'id')); + equal(dataset.docCount, 6); + }); +}); + +test('Memory Backend: query', function () { + var dataset = makeBackendDataset(); + // convenience for tests - get the data that should get changed + var data = dataset.backend.datasets[memoryData.metadata.id]; + var dataset = makeBackendDataset(); + var queryObj = { + size: 4 + , offset: 2 + }; + dataset.query(queryObj).then(function(documentList) { + deepEqual(data.documents[2], documentList.models[0].toJSON()); + }); +}); + +test('Memory Backend: query sort', function () { + var dataset = makeBackendDataset(); + // convenience for tests - get the data that should get changed + var data = dataset.backend.datasets[memoryData.metadata.id]; + var queryObj = { + sort: [ + ['y', 'desc'] + ] + }; + dataset.query(queryObj).then(function(docs) { + var doc0 = dataset.currentDocuments.models[0].toJSON(); + equal(doc0.x, 6); + }); +}); + +test('Memory Backend: update and delete', function () { + var dataset = makeBackendDataset(); + // convenience for tests - get the data that should get changed + var data = dataset.backend.datasets[memoryData.metadata.id]; + dataset.query().then(function(docList) { + equal(docList.length, Math.min(100, data.documents.length)); + var doc1 = docList.models[0]; + deepEqual(doc1.toJSON(), data.documents[0]); + + // Test UPDATE + var newVal = 10; + doc1.set({x: newVal}); + doc1.save().then(function() { + equal(data.documents[0].x, newVal); + }) + + // Test Delete + doc1.destroy().then(function() { + equal(data.documents.length, 5); + equal(data.documents[0].x, memoryData.documents[1].x); + }); + }); +}); + +// TODO: move to fixtures +var webstoreSchema = { + "count": 3, + "data": [ + { + "name": "__id__", + "type": "integer", + "values_url": "/rufuspollock/demo/data/distinct/__id__" + }, + { + "name": "date", + "type": "text", + "values_url": "/rufuspollock/demo/data/distinct/date" + }, + { + "name": "geometry", + "type": "text", + "values_url": "/rufuspollock/demo/data/distinct/geometry" + }, + { + "name": "amount", + "type": "text", + "values_url": "/rufuspollock/demo/data/distinct/amount" + } + ], + "fields": [ + { + "name": "type" + }, + { + "name": "name" + }, + { + "name": "values_url" + } + ] +}; + +webstoreData = { + "count": null, + "data": [ + { + "__id__": 1, + "amount": "100", + "date": "2009-01-01", + "geometry": null + }, + { + "__id__": 2, + "amount": "200", + "date": "2010-01-01", + "geometry": null + }, + { + "__id__": 3, + "amount": "300", + "date": "2011-01-01", + "geometry": null + } + ], + "fields": [ + { + "name": "__id__" + }, + { + "name": "date" + }, + { + "name": "geometry" + }, + { + "name": "amount" + } + ] +}; + +test('Webstore Backend', function() { + var dataset = new recline.Model.Dataset({ + id: 'my-id', + webstore_url: 'http://webstore.test.ckan.org/rufuspollock/demo/data' + }, + 'webstore' + ); + var stub = sinon.stub($, 'ajax', function(options) { + if (options.url.indexOf('schema.json') != -1) { + return { + done: function(callback) { + callback(webstoreSchema); + return this; + }, + fail: function() { + return this; + } + } + } else { + return { + done: function(callback) { + callback(webstoreData); + }, + fail: function() { + } + } + } + }); + + dataset.fetch().done(function(dataset) { + deepEqual(['__id__', 'date', 'geometry', 'amount'], _.pluck(dataset.fields.toJSON(), 'id')); + equal(3, dataset.docCount) + dataset.query().done(function(docList) { + equal(3, docList.length) + equal("2009-01-01", docList.models[0].get('date')); + }); + }); + $.ajax.restore(); +}); + + +var dataProxyData = { + "data": [ + [ + "1", + "1950-01", + "34.73" + ], + [ + "2", + "1950-02", + "34.73" + ], + [ + "3", + "1950-03", + "34.73" + ], + [ + "4", + "1950-04", + "34.73" + ], + [ + "5", + "1950-05", + "34.73" + ], + [ + "6", + "1950-06", + "34.73" + ], + [ + "7", + "1950-07", + "34.73" + ], + [ + "8", + "1950-08", + "34.73" + ], + [ + "9", + "1950-09", + "34.73" + ], + [ + "10", + "1950-10", + "34.73" + ] + ], + "fields": [ + "__id__", + "date", + "price" + ], + "length": null, + "max_results": 10, + "url": "http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv" +}; + +test('DataProxy Backend', function() { + // needed only if not stubbing + // stop(); + var dataset = new recline.Model.Dataset({ + url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv' + }, + 'dataproxy' + ); + + var stub = sinon.stub($, 'ajax', function(options) { + var partialUrl = 'jsonpdataproxy.appspot.com'; + if (options.url.indexOf(partialUrl) != -1) { + return { + done: function(callback) { + callback(dataProxyData); + return this; + }, + fail: function() { + return this; + } + } + } + }); + + dataset.fetch().done(function(dataset) { + deepEqual(['__id__', 'date', 'price'], _.pluck(dataset.fields.toJSON(), 'id')); + equal(null, dataset.docCount) + dataset.query().done(function(docList) { + equal(10, docList.length) + equal("1950-01", docList.models[0].get('date')); + // needed only if not stubbing + start(); + }); + }); + $.ajax.restore(); +}); + + +var sample_gdocs_spreadsheet_data = { + "feed": { + "category": [ + { + "term": "http://schemas.google.com/spreadsheets/2006#list", + "scheme": "http://schemas.google.com/spreadsheets/2006" + } + ], + "updated": { + "$t": "2010-07-12T18:32:16.200Z" + }, + "xmlns": "http://www.w3.org/2005/Atom", + "xmlns$gsx": "http://schemas.google.com/spreadsheets/2006/extended", + "title": { + "$t": "Sheet1", + "type": "text" + }, + "author": [ + { + "name": { + "$t": "okfn.rufus.pollock" + }, + "email": { + "$t": "okfn.rufus.pollock@gmail.com" + } + } + ], + "openSearch$startIndex": { + "$t": "1" + }, + "link": [ + { + "href": "http://spreadsheets.google.com/pub?key=0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc", + "type": "text/html", + "rel": "alternate" + }, + { + "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values", + "type": "application/atom+xml", + "rel": "http://schemas.google.com/g/2005#feed" + }, + { + "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json-in-script", + "type": "application/atom+xml", + "rel": "self" + } + ], + "xmlns$openSearch": "http://a9.com/-/spec/opensearchrss/1.0/", + "entry": [ + { + "category": [ + { + "term": "http://schemas.google.com/spreadsheets/2006#list", + "scheme": "http://schemas.google.com/spreadsheets/2006" + } + ], + "updated": { + "$t": "2010-07-12T18:32:16.200Z" + }, + "gsx$column-2": { + "$t": "1" + }, + "gsx$column-1": { + "$t": "A" + }, + "title": { + "$t": "A", + "type": "text" + }, + "content": { + "$t": "column-2: 1", + "type": "text" + }, + "link": [ + { + "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cokwr", + "type": "application/atom+xml", + "rel": "self" + } + ], + "id": { + "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cokwr" + } + }, + { + "category": [ + { + "term": "http://schemas.google.com/spreadsheets/2006#list", + "scheme": "http://schemas.google.com/spreadsheets/2006" + } + ], + "updated": { + "$t": "2010-07-12T18:32:16.200Z" + }, + "gsx$column-2": { + "$t": "2" + }, + "gsx$column-1": { + "$t": "b" + }, + "title": { + "$t": "b", + "type": "text" + }, + "content": { + "$t": "column-2: 2", + "type": "text" + }, + "link": [ + { + "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cpzh4", + "type": "application/atom+xml", + "rel": "self" + } + ], + "id": { + "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cpzh4" + } + }, + { + "category": [ + { + "term": "http://schemas.google.com/spreadsheets/2006#list", + "scheme": "http://schemas.google.com/spreadsheets/2006" + } + ], + "updated": { + "$t": "2010-07-12T18:32:16.200Z" + }, + "gsx$column-2": { + "$t": "3" + }, + "gsx$column-1": { + "$t": "c" + }, + "title": { + "$t": "c", + "type": "text" + }, + "content": { + "$t": "column-2: 3", + "type": "text" + }, + "link": [ + { + "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cre1l", + "type": "application/atom+xml", + "rel": "self" + } + ], + "id": { + "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cre1l" + } + } + ], + "openSearch$totalResults": { + "$t": "3" + }, + "id": { + "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values" + } + }, + "version": "1.0", + "encoding": "UTF-8" +} + +test("GDoc Backend", function() { + var dataset = new recline.Model.Dataset({ + url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json' + }, + 'gdocs' + ); + + var stub = sinon.stub($, 'getJSON', function(options, cb) { + console.log('options are', options, cb); + var partialUrl = 'spreadsheets.google.com'; + if (options.indexOf(partialUrl) != -1) { + cb(sample_gdocs_spreadsheet_data) + } + }); + + dataset.fetch().then(function(dataset) { + console.log('inside dataset:', dataset, dataset.fields, dataset.get('data')); + deepEqual(['column-2', 'column-1'], _.pluck(dataset.fields.toJSON(), 'id')); + //equal(null, dataset.docCount) + dataset.query().then(function(docList) { + equal(3, docList.length); + console.log(docList.models[0]); + equal("A", docList.models[0].get('column-1')); + // needed only if not stubbing + start(); + }); + }); + $.getJSON.restore(); +}); + +})(this.jQuery); diff --git a/test/index.html b/test/index.html index 219e2b3d..cd87c9af 100644 --- a/test/index.html +++ b/test/index.html @@ -17,13 +17,13 @@ - + + - - +

Qunit Tests

diff --git a/test/model.test.js b/test/model.test.js index d44ff330..2fe8f3a4 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,484 +1,31 @@ (function ($) { -module("Dataset"); +module("Model"); -test('Memory Backend', function () { - var datasetId = 'test-dataset'; - var inData = { - metadata: { - title: 'My Test Dataset' - , name: '1-my-test-dataset' - , id: datasetId - , headers: ['x', 'y', 'z'] - }, - documents: [ - {id: 0, x: 1, y: 2, z: 3} - , {id: 1, x: 2, y: 4, z: 6} - , {id: 2, x: 3, y: 6, z: 9} - , {id: 3, x: 4, y: 8, z: 12} - , {id: 4, x: 5, y: 10, z: 15} - , {id: 5, x: 6, y: 12, z: 18} - ] - }; - var backend = new recline.Model.BackendMemory(); - backend.addDataset(inData); - var dataset = new recline.Model.Dataset({id: datasetId}, backend); - // ### Start testing - expect(10); - // convenience for tests - var data = backend.datasets[datasetId]; - dataset.fetch().then(function(datasetAgain) { - equal(dataset.get('name'), data.metadata.name); - deepEqual(dataset.get('headers'), data.metadata.headers); - equal(dataset.docCount, 6); - var queryObj = { - size: 4 - , offset: 2 - }; - dataset.query(queryObj).then(function(documentList) { - deepEqual(data.documents[2], documentList.models[0].toJSON()); - }); - var queryObj = { - sort: [ - ['y', 'desc'] - ] - }; - dataset.query(queryObj).then(function(docs) { - var doc0 = dataset.currentDocuments.models[0].toJSON(); - equal(doc0.x, 6); - }); - dataset.query().then(function(docList) { - equal(docList.length, Math.min(100, data.documents.length)); - var doc1 = docList.models[0]; - deepEqual(doc1.toJSON(), data.documents[0]); - - // Test UPDATE - var newVal = 10; - doc1.set({x: newVal}); - doc1.save().then(function() { - equal(data.documents[0].x, newVal); - }) - - // Test Delete - doc1.destroy().then(function() { - equal(data.documents.length, 5); - equal(data.documents[0].x, inData.documents[1].x); - }); - }); +test('Field: basics', function () { + var field = new recline.Model.Field({ + id: 'x' }); + equal(field.attributes.label, 'x', 'Field label should be set from id'); + + var field = new recline.Model.Field({ + id: 'x', + label: 'My label' + }); + equal(field.attributes.label, 'My label', 'Field label should be set from id but not if explicitly provided'); + + raises(function() { + var field = new recline.Model.Field('xxx'); + }, + 'should throw an error if not passed in a hash with id' + ); }); -// TODO: move to fixtures -var webstoreSchema = { - "count": 3, - "data": [ - { - "name": "__id__", - "type": "integer", - "values_url": "/rufuspollock/demo/data/distinct/__id__" - }, - { - "name": "date", - "type": "text", - "values_url": "/rufuspollock/demo/data/distinct/date" - }, - { - "name": "geometry", - "type": "text", - "values_url": "/rufuspollock/demo/data/distinct/geometry" - }, - { - "name": "amount", - "type": "text", - "values_url": "/rufuspollock/demo/data/distinct/amount" - } - ], - "fields": [ - { - "name": "type" - }, - { - "name": "name" - }, - { - "name": "values_url" - } - ] -}; - -webstoreData = { - "count": null, - "data": [ - { - "__id__": 1, - "amount": "100", - "date": "2009-01-01", - "geometry": null - }, - { - "__id__": 2, - "amount": "200", - "date": "2010-01-01", - "geometry": null - }, - { - "__id__": 3, - "amount": "300", - "date": "2011-01-01", - "geometry": null - } - ], - "fields": [ - { - "name": "__id__" - }, - { - "name": "date" - }, - { - "name": "geometry" - }, - { - "name": "amount" - } - ] -}; - -test('Webstore Backend', function() { - var dataset = new recline.Model.Dataset({ - id: 'my-id', - webstore_url: 'http://webstore.test.ckan.org/rufuspollock/demo/data' - }, - 'webstore' - ); - var stub = sinon.stub($, 'ajax', function(options) { - if (options.url.indexOf('schema.json') != -1) { - return { - done: function(callback) { - callback(webstoreSchema); - return this; - }, - fail: function() { - return this; - } - } - } else { - return { - done: function(callback) { - callback(webstoreData); - }, - fail: function() { - } - } - } - }); - - dataset.fetch().done(function(dataset) { - deepEqual(['__id__', 'date', 'geometry', 'amount'], dataset.get('headers')); - equal(3, dataset.docCount) - dataset.query().done(function(docList) { - equal(3, docList.length) - equal("2009-01-01", docList.models[0].get('date')); - }); - }); - $.ajax.restore(); -}); - - -var dataProxyData = { - "data": [ - [ - "1", - "1950-01", - "34.73" - ], - [ - "2", - "1950-02", - "34.73" - ], - [ - "3", - "1950-03", - "34.73" - ], - [ - "4", - "1950-04", - "34.73" - ], - [ - "5", - "1950-05", - "34.73" - ], - [ - "6", - "1950-06", - "34.73" - ], - [ - "7", - "1950-07", - "34.73" - ], - [ - "8", - "1950-08", - "34.73" - ], - [ - "9", - "1950-09", - "34.73" - ], - [ - "10", - "1950-10", - "34.73" - ] - ], - "fields": [ - "__id__", - "date", - "price" - ], - "length": null, - "max_results": 10, - "url": "http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv" -}; - -test('DataProxy Backend', function() { - // needed only if not stubbing - // stop(); - var dataset = new recline.Model.Dataset({ - url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv' - }, - 'dataproxy' - ); - - var stub = sinon.stub($, 'ajax', function(options) { - var partialUrl = 'jsonpdataproxy.appspot.com'; - if (options.url.indexOf(partialUrl) != -1) { - return { - done: function(callback) { - callback(dataProxyData); - return this; - }, - fail: function() { - return this; - } - } - } - }); - - dataset.fetch().done(function(dataset) { - deepEqual(['__id__', 'date', 'price'], dataset.get('headers')); - equal(null, dataset.docCount) - dataset.query().done(function(docList) { - equal(10, docList.length) - equal("1950-01", docList.models[0].get('date')); - // needed only if not stubbing - start(); - }); - }); - $.ajax.restore(); -}); - - -var sample_gdocs_spreadsheet_data = { - "feed": { - "category": [ - { - "term": "http://schemas.google.com/spreadsheets/2006#list", - "scheme": "http://schemas.google.com/spreadsheets/2006" - } - ], - "updated": { - "$t": "2010-07-12T18:32:16.200Z" - }, - "xmlns": "http://www.w3.org/2005/Atom", - "xmlns$gsx": "http://schemas.google.com/spreadsheets/2006/extended", - "title": { - "$t": "Sheet1", - "type": "text" - }, - "author": [ - { - "name": { - "$t": "okfn.rufus.pollock" - }, - "email": { - "$t": "okfn.rufus.pollock@gmail.com" - } - } - ], - "openSearch$startIndex": { - "$t": "1" - }, - "link": [ - { - "href": "http://spreadsheets.google.com/pub?key=0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc", - "type": "text/html", - "rel": "alternate" - }, - { - "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values", - "type": "application/atom+xml", - "rel": "http://schemas.google.com/g/2005#feed" - }, - { - "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json-in-script", - "type": "application/atom+xml", - "rel": "self" - } - ], - "xmlns$openSearch": "http://a9.com/-/spec/opensearchrss/1.0/", - "entry": [ - { - "category": [ - { - "term": "http://schemas.google.com/spreadsheets/2006#list", - "scheme": "http://schemas.google.com/spreadsheets/2006" - } - ], - "updated": { - "$t": "2010-07-12T18:32:16.200Z" - }, - "gsx$column-2": { - "$t": "1" - }, - "gsx$column-1": { - "$t": "A" - }, - "title": { - "$t": "A", - "type": "text" - }, - "content": { - "$t": "column-2: 1", - "type": "text" - }, - "link": [ - { - "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cokwr", - "type": "application/atom+xml", - "rel": "self" - } - ], - "id": { - "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cokwr" - } - }, - { - "category": [ - { - "term": "http://schemas.google.com/spreadsheets/2006#list", - "scheme": "http://schemas.google.com/spreadsheets/2006" - } - ], - "updated": { - "$t": "2010-07-12T18:32:16.200Z" - }, - "gsx$column-2": { - "$t": "2" - }, - "gsx$column-1": { - "$t": "b" - }, - "title": { - "$t": "b", - "type": "text" - }, - "content": { - "$t": "column-2: 2", - "type": "text" - }, - "link": [ - { - "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cpzh4", - "type": "application/atom+xml", - "rel": "self" - } - ], - "id": { - "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cpzh4" - } - }, - { - "category": [ - { - "term": "http://schemas.google.com/spreadsheets/2006#list", - "scheme": "http://schemas.google.com/spreadsheets/2006" - } - ], - "updated": { - "$t": "2010-07-12T18:32:16.200Z" - }, - "gsx$column-2": { - "$t": "3" - }, - "gsx$column-1": { - "$t": "c" - }, - "title": { - "$t": "c", - "type": "text" - }, - "content": { - "$t": "column-2: 3", - "type": "text" - }, - "link": [ - { - "href": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cre1l", - "type": "application/atom+xml", - "rel": "self" - } - ], - "id": { - "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values/cre1l" - } - } - ], - "openSearch$totalResults": { - "$t": "3" - }, - "id": { - "$t": "http://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values" - } - }, - "version": "1.0", - "encoding": "UTF-8" -} - -test("GDoc Backend", function() { - var dataset = new recline.Model.Dataset({ - url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json' - }, - 'gdocs' - ); - - var stub = sinon.stub($, 'getJSON', function(options, cb) { - console.log('options are', options, cb); - var partialUrl = 'spreadsheets.google.com'; - if (options.indexOf(partialUrl) != -1) { - cb(sample_gdocs_spreadsheet_data) - } - }); - - dataset.fetch().then(function(dataset) { - console.log('inside dataset:', dataset, dataset.get('headers'), dataset.get('data')); - deepEqual(['column-2', 'column-1'], dataset.get('headers')); - //equal(null, dataset.docCount) - dataset.query().then(function(docList) { - equal(3, docList.length); - console.log(docList.models[0]); - equal("A", docList.models[0].get('column-1')); - // needed only if not stubbing - start(); - }); - }); - $.getJSON.restore(); +test('Dataset', function () { + var meta = {id: 'test', title: 'xyz'}; + var dataset = new recline.Model.Dataset(meta); + dataset.fields = new recline.Model.FieldList([{id: 'xx'}, {id: 'yy'}]); + var out = dataset.toTemplateJSON(); + equal(out.fields.length, 2); }); })(this.jQuery); diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 00000000..dc18b60d --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,22 @@ +(function ($) { +module("Util"); + +test('parseHashUrl', function () { + var out = recline.View.parseHashUrl('graph?x=y'); + equal(out.path, 'graph'); + equal(out.query, '?x=y'); + var out = recline.View.parseHashUrl('graph'); + equal(out.path, 'graph'); + equal(out.query, ''); +}); + +test('composeQueryString', function () { + var params = { + x: 'y', + a: 'b' + }; + var out = recline.View.composeQueryString(params); + equal(out, '?x="y"&a="b"'); +}); + +})(this.jQuery); diff --git a/test/view.test.js b/test/view.test.js index e8eb9755..3b9c62e5 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -13,13 +13,13 @@ test('new DataTableRow View', function () { var view = new recline.View.DataTableRow({ model: doc , el: $el - , headers: ['a', 'b'] + , fields: new recline.Model.FieldList([{id: 'a'}, {id: 'b'}]) }); view.render(); ok($el.attr('data-id'), '1'); var tds = $el.find('td'); equal(tds.length, 3); - equal($(tds[1]).attr('data-header'), 'a'); + equal($(tds[1]).attr('data-field'), 'a'); }); })(this.jQuery);

model.js

Recline Backbone Models

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

A Dataset model

-

Other than standard list of Backbone methods it has two important attributes:

+

A model must have the following (Backbone) attributes:

    +
  • fields: (aka columns) is a FieldList listing all the fields on this +Dataset (this can be set explicitly, or, on fetch() of Dataset +information from the backend, or as is perhaps most common on the first +query)
  • currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows)
  • docCount: total number of documents in this dataset (obtained on a fetch for this Dataset)
  • -
  my.Dataset = Backbone.Model.extend({
-    __type__: 'Dataset',
-    initialize: function(model, backend) {
-      this.backend = backend;
-      if (backend && backend.constructor == String) {
-        this.backend = my.backends[backend];
-      }
-      this.currentDocuments = new my.DocumentList();
-      this.docCount = null;
-      this.defaultQuery = {
-        size: 100
-        , offset: 0
-      };

this.queryState = {};

    },

getDocuments

+
my.Dataset = Backbone.Model.extend({
+  __type__: 'Dataset',
+  initialize: function(model, backend) {
+    _.bindAll(this, 'query');
+    this.backend = backend;
+    if (backend && backend.constructor == String) {
+      this.backend = my.backends[backend];
+    }
+    this.fields = new my.FieldList();
+    this.currentDocuments = new my.DocumentList();
+    this.docCount = null;
+    this.queryState = new my.Query();
+    this.queryState.bind('change', this.query);
+  },

query

-

AJAX method with promise API to get rows (documents) from the backend.

+

AJAX method with promise API to get documents from the backend.

+ +

It will query based on current query state (given by this.queryState) +updated by queryObj (if provided).

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

- -

:param numRows: passed onto backend getDocuments. -:param start: passed onto backend getDocuments.

- -

this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. -This also illustrates the limitations of separating the Dataset and the Backend

    query: function(queryObj) {
-      var self = this;
-      this.queryState = queryObj || this.defaultQuery;
-      this.queryState = _.extend({size: 100, offset: 0}, this.queryState);
-      var dfd = $.Deferred();
-      this.backend.query(this, this.queryState).done(function(rows) {
-        var docs = _.map(rows, function(row) {
-          var _doc = new my.Document(row);
-          _doc.backend = self.backend;
-          _doc.dataset = self;
-          return _doc;
-        });
-        self.currentDocuments.reset(docs);
-        dfd.resolve(self.currentDocuments);
-      })
-      .fail(function(arguments) {
-        dfd.reject(arguments);
+also returned.

  query: function(queryObj) {
+    var self = this;
+    this.queryState.set(queryObj, {silent: true});
+    var dfd = $.Deferred();
+    this.backend.query(this, this.queryState.toJSON()).done(function(rows) {
+      var docs = _.map(rows, function(row) {
+        var _doc = new my.Document(row);
+        _doc.backend = self.backend;
+        _doc.dataset = self;
+        return _doc;
       });
-      return dfd.promise();
-    },
+      self.currentDocuments.reset(docs);
+      dfd.resolve(self.currentDocuments);
+    })
+    .fail(function(arguments) {
+      dfd.reject(arguments);
+    });
+    return dfd.promise();
+  },
 
-    toTemplateJSON: function() {
-      var data = this.toJSON();
-      data.docCount = this.docCount;
-      return data;
+  toTemplateJSON: function() {
+    var data = this.toJSON();
+    data.docCount = this.docCount;
+    data.fields = this.fields.toJSON();
+    return data;
+  }
+});

A Document (aka Row)

+ +

A single entry or row in the dataset

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

A Backbone collection of Documents

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

A Field (aka Column) on a Dataset

+ +

Following attributes as standard:

+ +
    +
  • id: a unique identifer for this field- usually this should match the key in the documents hash
  • +
  • label: the visible label used for this field
  • +
  • type: the type of the data
  • +
my.Field = Backbone.Model.extend({
+  defaults: {
+    id: null,
+    label: null,
+    type: 'String'
+  },

In addition to normal backbone initialization via a Hash you can also +just pass a single argument representing id to the ctor

  initialize: function(data) {

if a hash not passed in the first argument is set as value for key 0

    if ('0' in data) {
+      throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
     }
-  });

A Document (aka Row)

+ if (this.attributes.label == null) { + this.set({label: this.id}); + } + } +}); -

A single entry or row in the dataset

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

A Backbone collection of Documents

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

Backend registry

+my.FieldList = Backbone.Collection.extend({ + model: my.Field +});

A Query object storing Dataset Query state

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

Backend registry

-

Backends will register themselves by id into this registry

  my.backends = {};
+

Backends will register themselves by id into this registry

my.backends = {};
 
 }(jQuery, this.recline.Model));
 
diff --git a/docs/view-flot-graph.html b/docs/view-flot-graph.html
new file mode 100644
index 00000000..aa18c0b8
--- /dev/null
+++ b/docs/view-flot-graph.html
@@ -0,0 +1,213 @@
+      view-flot-graph.js           

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.

+ +

Initialization arguments:

+ +
    +
  • model: recline.Model.Dataset
  • +
  • config: (optional) graph configuration hash of form:

    + +

    { + group: {column name for x-axis}, + series: [{column name for series A}, {column name series B}, ... ], + graphType: 'line' + }

  • +
+ +

NB: should not provide an el argument to the view but must let the view +generate the element itself (you can then append view.el to the DOM.

my.FlotGraph = Backbone.View.extend({
+
+  tagName:  "div",
+  className: "data-graph-container",
+
+  template: ' \
+  <div class="editor"> \
+    <div class="editor-info editor-hide-info"> \
+      <h3 class="action-toggle-help">Help &raquo;</h3> \
+      <p>To create a chart select a column (group) to use as the x-axis \
+         then another column (Series A) to plot against it.</p> \
+      <p>You can add add \
+         additional series by clicking the "Add series" button</p> \
+    </div> \
+    <form class="form-stacked"> \
+      <div class="clearfix"> \
+        <label>Graph Type</label> \
+        <div class="input editor-type"> \
+          <select> \
+          <option value="line">Line</option> \
+          </select> \
+        </div> \
+        <label>Group Column (x-axis)</label> \
+        <div class="input editor-group"> \
+          <select> \
+          {{#fields}} \
+          <option value="{{id}}">{{label}}</option> \
+          {{/fields}} \
+          </select> \
+        </div> \
+        <div class="editor-series-group"> \
+          <div class="editor-series"> \
+            <label>Series <span>A (y-axis)</span></label> \
+            <div class="input"> \
+              <select> \
+              {{#fields}} \
+              <option value="{{id}}">{{label}}</option> \
+              {{/fields}} \
+              </select> \
+            </div> \
+          </div> \
+        </div> \
+      </div> \
+      <div class="editor-buttons"> \
+        <button class="btn editor-add">Add Series</button> \
+      </div> \
+      <div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
+        <button class="editor-save">Save</button> \
+        <input type="hidden" class="editor-id" value="chart-1" /> \
+      </div> \
+    </form> \
+  </div> \
+  <div class="panel graph"></div> \
+</div> \
+',
+
+  events: {
+    'change form select': 'onEditorSubmit'
+    , 'click .editor-add': 'addSeries'
+    , 'click .action-remove-series': 'removeSeries'
+    , 'click .action-toggle-help': 'toggleHelp'
+  },
+
+  initialize: function(options, config) {
+    var self = this;
+    this.el = $(this.el);
+    _.bindAll(this, 'render', 'redraw');

we need the model.fields to render properly

    this.model.bind('change', this.render);
+    this.model.fields.bind('reset', this.render);
+    this.model.fields.bind('add', this.render);
+    this.model.currentDocuments.bind('add', this.redraw);
+    this.model.currentDocuments.bind('reset', this.redraw);
+    var configFromHash = my.parseHashQueryString().graph;
+    if (configFromHash) {
+      configFromHash = JSON.parse(configFromHash);
+    }
+    this.chartConfig = _.extend({
+        group: null,
+        series: [],
+        graphType: 'line'
+      },
+      configFromHash,
+      config
+      );
+    this.render();
+  },
+
+  render: function() {
+    htmls = $.mustache(this.template, this.model.toTemplateJSON());
+    $(this.el).html(htmls);

now set a load of stuff up

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

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

    this.$seriesClone = this.el.find('.editor-series').clone();
+    this._updateSeries();
+    return this;
+  },
+
+  onEditorSubmit: function(e) {
+    var select = this.el.find('.editor-group select');
+    this._getEditorData();

update navigation +TODO: make this less invasive (e.g. preserve other keys in query string)

    var qs = my.parseHashQueryString();
+    qs['graph'] = this.chartConfig;
+    my.setHashQueryString(qs);
+    this.redraw();
+  },
+
+  redraw: function() {

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

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

    + +

    Uncaught Invalid dimensions for plot, width = 0, height = 0

  • +
  • There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
  • +
    var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
+    if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) {
+      return
+    }

create this.plot and cache it

    if (!this.plot) {

only lines for the present

      options = {
+        id: 'line',
+        name: 'Line Chart'
+      };
+      this.plot = $.plot(this.$graph, this.createSeries(), options);
+    } 
+    this.plot.setData(this.createSeries());
+    this.plot.resize();
+    this.plot.setupGrid();
+    this.plot.draw();
+  },
+
+  _getEditorData: function() {
+    $editor = this
+    var series = this.$series.map(function () {
+      return $(this).val();
+    });
+    this.chartConfig.series = $.makeArray(series)
+    this.chartConfig.group = this.el.find('.editor-group select').val();
+  },
+
+  createSeries: function () {
+    var self = this;
+    var series = [];
+    if (this.chartConfig) {
+      $.each(this.chartConfig.series, function (seriesIndex, field) {
+        var points = [];
+        $.each(self.model.currentDocuments.models, function (index, doc) {
+          var x = doc.get(self.chartConfig.group);
+          var y = doc.get(field);
+          if (typeof x === 'string') {
+            x = index;
+          }
+          points.push([x, y]);
+        });
+        series.push({data: points, label: field});
+      });
+    }
+    return series;
+  },

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

+ +

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

+ +

Returns itself.

  addSeries: function (e) {
+    e.preventDefault();
+    var element = this.$seriesClone.clone(),
+        label   = element.find('label'),
+        index   = this.$series.length;
+
+    this.el.find('.editor-series-group').append(element);
+    this._updateSeries();
+    label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
+    label.find('span').text(String.fromCharCode(this.$series.length + 64));
+    return this;
+  },

Public: Removes a series list item from the editor.

+ +

Also updates the labels of the remaining series elements.

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

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

+ +

Returns itself.

  _updateSeries: function () {
+    this.$series  = this.el.find('.editor-series select');
+  }
+});
+
+})(jQuery, recline.View);
+
+
\ No newline at end of file diff --git a/docs/view.html b/docs/view.html index 11cf161d..18edf6c3 100644 --- a/docs/view.html +++ b/docs/view.html @@ -1,33 +1,49 @@ - view.js
\ - {{/headers}} \ + {{/fields}} \ \ \ \ @@ -1139,14 +1409,15 @@ my.DataTable = Backbone.View.extend({ toTemplateJSON: function() { var modelData = this.model.toJSON() - modelData.notEmpty = ( this.headers.length > 0 ) - modelData.headers = this.headers; + 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.headers = _.filter(this.model.get('headers'), function(header) { - return _.indexOf(self.hiddenHeaders, header) == -1; + 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); @@ -1156,23 +1427,23 @@ my.DataTable = Backbone.View.extend({ var newView = new my.DataTableRow({ model: doc, el: tr, - headers: self.headers, + fields: self.fields, }); newView.render(); }); - $(".root-header-menu").toggle((self.hiddenHeaders.length > 0)); + this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); return this; } }); -// DataTableRow View for rendering an individual document. +// ## 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 headers in the constructor options. This should be list of headers for the DataTable. +// 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) { _.bindAll(this, 'render'); - this._headers = options.headers; + this._fields = options.fields; this.el = $(this.el); this.model.bind('change', this.render); }, @@ -1180,7 +1451,7 @@ my.DataTableRow = Backbone.View.extend({ template: ' \ \ {{#cells}} \ - '); - self.el.find('tbody').append(tr); - var newView = new my.DataTableRow({ - model: doc, - el: tr, - headers: self.headers, - }); - newView.render(); - }); - this.el.toggleClass('no-hidden', (self.hiddenHeaders.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 headers in the constructor options. This should be list of headers for the DataTable. -my.DataTableRow = Backbone.View.extend({ - initialize: function(options) { - _.bindAll(this, 'render'); - this._headers = options.headers; - this.el = $(this.el); - this.model.bind('change', this.render); - }, - - template: ' \ - \ - {{#cells}} \ - \ - {{/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 doc = this.model; - var cellData = _.map(this._headers, function(header) { - return {header: header, value: doc.get(header)} - }) - 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"); - } - $(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 header = cell.parents('td').attr('data-header'); - var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); - var newData = {}; - newData[header] = 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-flot-graph.js b/src/view-flot-graph.js index 4436a5a2..a27179ba 100644 --- a/src/view-flot-graph.js +++ b/src/view-flot-graph.js @@ -1,10 +1,9 @@ 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: // @@ -44,9 +43,9 @@ my.FlotGraph = Backbone.View.extend({ \
\ \
\
\ @@ -54,9 +53,9 @@ my.FlotGraph = Backbone.View.extend({ \
\ \
\
\ @@ -86,25 +85,29 @@ my.FlotGraph = Backbone.View.extend({ var self = this; this.el = $(this.el); _.bindAll(this, 'render', 'redraw'); - // we need the model.headers to render properly + // we need the model.fields to render properly this.model.bind('change', this.render); + this.model.fields.bind('reset', this.render); + this.model.fields.bind('add', this.render); this.model.currentDocuments.bind('add', this.redraw); this.model.currentDocuments.bind('reset', this.redraw); + var configFromHash = my.parseHashQueryString().graph; + if (configFromHash) { + configFromHash = JSON.parse(configFromHash); + } this.chartConfig = _.extend({ group: null, series: [], graphType: 'line' }, - config) + configFromHash, + config + ); this.render(); }, - toTemplateJSON: function() { - return this.model.toJSON(); - }, - render: function() { - htmls = $.mustache(this.template, this.toTemplateJSON()); + htmls = $.mustache(this.template, this.model.toTemplateJSON()); $(this.el).html(htmls); // now set a load of stuff up this.$graph = this.el.find('.panel.graph'); @@ -120,8 +123,9 @@ my.FlotGraph = Backbone.View.extend({ this._getEditorData(); // update navigation // TODO: make this less invasive (e.g. preserve other keys in query string) - window.location.hash = window.location.hash.split('?')[0] + - '?graph=' + JSON.stringify(this.chartConfig); + var qs = my.parseHashQueryString(); + qs['graph'] = this.chartConfig; + my.setHashQueryString(qs); this.redraw(); }, diff --git a/src/view.js b/src/view.js index 5e79a62a..32f7cc9c 100644 --- a/src/view.js +++ b/src/view.js @@ -1,11 +1,511 @@ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; -// Views module following classic module pattern (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 +// +// 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: ' \ + \ +
    \ +

    view.js

    this.recline = this.recline || {};

    Views module following classic module pattern

    recline.View = function($) {
    +      view.js           
    render:function(){vartmplData=this.model.toTemplateJSON();tmplData.displayCount=this.config.displayCount; + tmplData.views=this.pageViews;vartemplate=$.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.el) + $dataViewContainer.append(view.view.el)});},setupRouting:function(){ - varself=this; - this.router.route('','grid',function(){ - self.updateNav('grid'); + varself=this;

    view.js

    this.recline = this.recline || {};
    +this.recline.View = this.recline.View || {};
     
    -var my = {};

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

    function parseQueryString(q) {
    -  var urlParams = {},
    -    e, d = function (s) {
    -      return unescape(s.replace(/\+/g, " "));
    -    },
    -    r = /([^&=]+)=?([^&]*)/g;
    +(function($, my) {

    DataExplorer

    - if (q && q.length && q[0] === '?') { - q = q.slice(1); - } - while (e = r.exec(q)) {

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

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

    The primary view for the entire application.

    +

    The primary view for the entire application. Usage:

    -

    It should be initialized with a recline.Model.Dataset object and an existing -dom element to attach to (the existing DOM element is important for -rendering of FlotGraph subview).

    +
    +var myExplorer = new model.recline.DataExplorer({
    +  model: {{recline.Model.Dataset instance}}
    +  el: {{an existing dom element}}
    +  views: {{page views}}
    +  config: {{config options -- see below}}
    +});
    +
    -

    To pass in configuration options use the config key in initialization hash -e.g.

    +

    Parameters

    -
     var explorer = new DataExplorer({
    -   config: {...}
    - })
    -
    +

    model: (required) Dataset instance.

    -

    Config options:

    +

    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)
    • @@ -35,19 +51,21 @@ e.g.

      operate in read-only mode (hiding all editing options).
    -

    All other views as contained in this one.

    my.DataExplorer = Backbone.View.extend({
    +

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

    my.DataExplorer = Backbone.View.extend({
       template: ' \
       <div class="data-explorer"> \
         <div class="alert-messages"></div> \
         \
         <div class="header"> \
           <ul class="navigation"> \
    -        <li class="active"><a href="#grid" class="btn">Grid</a> \
    -        <li><a href="#graph" class="btn">Graph</a></li> \
    +        {{#views}} \
    +        <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}}" /> of  <span class="doc-count">{{docCount}}</span> \
    +          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> \
         </div> \
    @@ -68,33 +86,57 @@ operate in read-only mode (hiding all editing options).
       initialize: function(options) {
         var self = this;
         this.el = $(this.el);
    -    this.config = options.config || {};
    -    _.extend(this.config, {
    -      displayCount: 10
    -      , readOnly: false
    -    });
    +    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

        this.pageViews = {
    -      grid: new my.DataTable({
    -          model: this.model
    -        })
    -      , graph: new my.FlotGraph({
    -          model: this.model
    -        })
    -    };

    this must be called after pageViews are created

        this.render();
    +    }

    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 headers etc -note this.model and dataset returned are the same

        this.model.fetch().then(function(dataset) {
    -      self.el.find('.doc-count').text(self.model.docCount);

    initialize of dataTable calls render

          self.model.getDocuments(self.config.displayCount);
    -    });
    +    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.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val());
    -    this.model.getDocuments(this.config.displayCount);
    +    this.query();
       },
     
       setReadOnly: function() {
    @@ -104,45 +146,40 @@ note this.model and dataset returned are the same

    Default route

        this.router.route('', this.pageViews[0].id, function() {
    +      self.updateNav(self.pageViews[0].id);
         });
    -    this.router.route(/grid(\?.*)?/, 'view', function(queryString) {
    -      self.updateNav('grid', queryString);
    -    });
    -    this.router.route(/graph(\?.*)?/, 'graph', function(queryString) {
    -      self.updateNav('graph', queryString);

    we have to call here due to fact plot may not have been able to draw -if it was hidden until now - see comments in FlotGraph.redraw

          qsParsed = parseQueryString(queryString);
    -      if ('graph' in qsParsed) {
    -        var chartConfig = JSON.parse(qsParsed['graph']);
    -        _.extend(self.pageViews['graph'].chartConfig, chartConfig);
    -      }
    -      self.pageViews['graph'].redraw();
    +    $.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, pageViewName) {
    -      if (pageViewName === pageName) {
    -        view.el.show();
    +    $el.parent().addClass('active');

    show the specific page

        _.each(this.pageViews, function(view, idx) {
    +      if (view.id === pageName) {
    +        view.view.el.show();
           } else {
    -        view.el.hide();
    +        view.view.el.hide();
           }
         });
       }
    -});

    DataTable provides a tabular view on a Dataset.

    +});

    DataTable

    + +

    Provides a tabular view on a Dataset.

    Initialize it with a recline.Dataset object.

    my.DataTable = Backbone.View.extend({
       tagName:  "div",
    @@ -156,13 +193,15 @@ if it was hidden until now - see comments in FlotGraph.redraw

    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)). + },

    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'); @@ -171,9 +210,9 @@ showDialog: function(template, data) { util.hide('dialog'); }) $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); -},

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

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

      onColumnHeaderClick: function(e) {
    -    this.state.currentColumn = $(e.target).siblings().text();
    +    this.state.currentColumn = $(e.target).closest('.column-header').attr('data-field');
         util.position('data-table-menu', e);
         util.render('columnActions', 'data-table-menu');
       },
    @@ -183,31 +222,40 @@ Column and row menus

    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') },

    TODO: Delete or re-implement ...

          csv: function() { window.location.href = app.csvUrl },
    +      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');
    +      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 + 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);
    -            util.notify("Row deleted successfully");
    +            my.notify("Row deleted successfully");
               })
               .fail(function(err) {
    -            util.notify("Errorz! " + err)
    +            my.notify("Errorz! " + err)
               })
           }
         }
    @@ -234,7 +282,7 @@ from DOM) while id may be int

    showTransformDialog: function() { var $el = $('.dialog-content'); util.show('dialog'); - var view = new my.DataTransform({ + var view = new recline.View.DataTransform({ }); view.render(); $el.empty(); @@ -243,23 +291,49 @@ from DOM) while id may be int

    util.hide('dialog'); }) $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); - },

    ====================================================== -Core Templating

      template: ' \
    +  },
    +
    +  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"></th>{{/notEmpty}} \
    -          {{#headers}} \
    +          {{#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">{{.}}</span> \
    +                <span class="column-header-name">{{label}}</span> \
                   </div> \
                   </div> \
                 </th> \
    -          {{/headers}} \
    +          {{/fields}} \
             </tr> \
           </thead> \
           <tbody></tbody> \
    @@ -268,11 +342,14 @@ Core Templating

    toTemplateJSON: function() { var modelData = this.model.toJSON() - modelData.notEmpty = ( modelData.headers.length > 0 ) + 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) {
    @@ -281,26 +358,28 @@ Core Templating

    var newView = new my.DataTableRow({ model: doc, el: tr, - headers: self.model.get('headers') + fields: self.fields, }); newView.render(); }); + this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); return this; } -});

    DataTableRow View for rendering an individual document.

    +});

    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 headers in the constructor options. This should be list of headers for the DataTable.

    my.DataTableRow = Backbone.View.extend({
    +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) {
         _.bindAll(this, 'render');
    -    this._headers = options.headers;
    +    this._fields = options.fields;
         this.el = $(this.el);
         this.model.bind('change', this.render);
       },
    +
       template: ' \
           <td><a class="row-header-menu"></a></td> \
           {{#cells}} \
    -      <td data-header="{{header}}"> \
    +      <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> \
    @@ -309,14 +388,14 @@ In addition you must pass in a headers in the constructor options. This should b
           {{/cells}} \
         ',
       events: {
    -    'click .data-table-cell-edit': 'onEditClick',

    cell editor

        'click .data-table-cell-editor .okButton': 'onEditorOK',
    +    '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 doc = this.model;
    -    var cellData = _.map(this._headers, function(header) {
    -      return {header: header, value: doc.get(header)}
    +    var cellData = this._fields.map(function(field) {
    +      return {field: field.id, value: doc.get(field.id)}
         })
         return { id: this.id, cells: cellData }
       },
    @@ -326,8 +405,7 @@ In addition you must pass in a headers in the constructor options. This should b
         var html = $.mustache(this.template, this.toTemplateJSON());
         $(this.el).html(html);
         return this;
    -  },

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

      onEditClick: function(e) {
    +  },

    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");
    @@ -341,17 +419,17 @@ Cell Editor

    onEditorOK: function(e) { var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); - var header = cell.parents('td').attr('data-header'); + 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[header] = newValue; + newData[field] = newValue; this.model.set(newData); - util.notify("Updating row...", {loader: true}); + my.notify("Updating row...", {loader: true}); this.model.save().then(function(response) { - util.notify("Row updated successfully", {category: 'success'}); + my.notify("Row updated successfully", {category: 'success'}); }) .fail(function() { - util.notify('Error saving row', { + my.notify('Error saving row', { category: 'error', persist: true }); @@ -362,394 +440,86 @@ Cell Editor

    var cell = $(e.target).parents('.data-table-cell-value'); cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); } -});

    View (Dialog) for doing data transformations (on columns of data).

    my.ColumnTransform = Backbone.View.extend({
    -  className: 'transform-column-view',
    -  template: ' \
    -    <div class="dialog-header"> \
    -      Functional transform on column {{name}} \
    -    </div> \
    -    <div class="dialog-body"> \
    -      <div class="grid-layout layout-tight layout-full"> \
    -        <table> \
    -        <tbody> \
    -        <tr> \
    -          <td colspan="4"> \
    -            <div class="grid-layout layout-tight layout-full"> \
    -              <table rows="4" cols="4"> \
    -              <tbody> \
    -              <tr style="vertical-align: bottom;"> \
    -                <td colspan="4"> \
    -                  Expression \
    -                </td> \
    -              </tr> \
    -              <tr> \
    -                <td colspan="3"> \
    -                  <div class="input-container"> \
    -                    <textarea class="expression-preview-code"></textarea> \
    -                  </div> \
    -                </td> \
    -                <td class="expression-preview-parsing-status" width="150" style="vertical-align: top;"> \
    -                  No syntax error. \
    -                </td> \
    -              </tr> \
    -              <tr> \
    -                <td colspan="4"> \
    -                  <div id="expression-preview-tabs" class="refine-tabs ui-tabs ui-widget ui-widget-content ui-corner-all"> \
    -                    <span>Preview</span> \
    -                    <div id="expression-preview-tabs-preview" class="ui-tabs-panel ui-widget-content ui-corner-bottom"> \
    -                      <div class="expression-preview-container" style="width: 652px; "> \
    -                      </div> \
    -                    </div> \
    -                  </div> \
    -                </td> \
    -              </tr> \
    -              </tbody> \
    -              </table> \
    -            </div> \
    -          </td> \
    -        </tr> \
    -        </tbody> \
    -        </table> \
    -      </div> \
    -    </div> \
    -    <div class="dialog-footer"> \
    -      <button class="okButton btn primary">&nbsp;&nbsp;Update All&nbsp;&nbsp;</button> \
    -      <button class="cancelButton btn danger">Cancel</button> \
    -    </div> \
    -  ',
    -
    -  events: {
    -    'click .okButton': 'onSubmit'
    -    , 'keydown .expression-preview-code': 'onEditorKeydown'
    -  },
    -
    -  initialize: function() {
    -    this.el = $(this.el);
    -  },
    -
    -  render: function() {
    -    var htmls = $.mustache(this.template, 
    -      {name: this.state.currentColumn}
    -      )
    -    this.el.html(htmls);

    Put in the basic (identity) transform script -TODO: put this into the template?

        var editor = this.el.find('.expression-preview-code');
    -    editor.val("function(doc) {\n  doc['"+ this.state.currentColumn+"'] = doc['"+ this.state.currentColumn+"'];\n  return doc;\n}");
    -    editor.focus().get(0).setSelectionRange(18, 18);
    -    editor.keydown();
    -  },
    -
    -  onSubmit: function(e) {
    -    var self = this;
    -    var funcText = this.el.find('.expression-preview-code').val();
    -    var editFunc = costco.evalFunction(funcText);
    -    if (editFunc.errorMessage) {
    -      util.notify("Error with function! " + editFunc.errorMessage);
    -      return;
    -    }
    -    util.hide('dialog');
    -    util.notify("Updating all visible docs. This could take a while...", {persist: true, loader: true});
    -      var docs = self.model.currentDocuments.map(function(doc) {
    -       return doc.toJSON();
    -      });

    TODO: notify about failed docs?

        var toUpdate = costco.mapDocs(docs, editFunc).edited;
    -    var totalToUpdate = toUpdate.length;
    -    function onCompletedUpdate() {
    -      totalToUpdate += -1;
    -      if (totalToUpdate === 0) {
    -        util.notify(toUpdate.length + " documents updated successfully");
    -        alert('WARNING: We have only updated the docs in this view. (Updating of all docs not yet implemented!)');
    -        self.remove();
    -      }
    -    }

    TODO: Very inefficient as we search through all docs every time!

        _.each(toUpdate, function(editedDoc) {
    -      var realDoc = self.model.currentDocuments.get(editedDoc.id);
    -      realDoc.set(editedDoc);
    -      realDoc.save().then(onCompletedUpdate).fail(onCompletedUpdate)
    -    });
    -  },
    -
    -  onEditorKeydown: function(e) {
    -    var self = this;

    if you don't setTimeout it won't grab the latest character if you call e.target.value

        window.setTimeout( function() {
    -      var errors = self.el.find('.expression-preview-parsing-status');
    -      var editFunc = costco.evalFunction(e.target.value);
    -      if (!editFunc.errorMessage) {
    -        errors.text('No syntax error.');
    -        var docs = self.model.currentDocuments.map(function(doc) {
    -          return doc.toJSON();
    -        });
    -        var previewData = costco.previewTransform(docs, editFunc, self.state.currentColumn);
    -        util.render('editPreview', 'expression-preview-container', {rows: previewData});
    -      } else {
    -        errors.text(editFunc.errorMessage);
    -      }
    -    }, 1, true);
    -  }
    -});

    View (Dialog) for doing data transformations on whole dataset.

    my.DataTransform = Backbone.View.extend({
    -  className: 'transform-view',
    -  template: ' \
    -    <div class="dialog-header"> \
    -      Recursive transform on all rows \
    -    </div> \
    -    <div class="dialog-body"> \
    -      <div class="grid-layout layout-full"> \
    -        <p class="info">Traverse and transform objects by visiting every node on a recursive walk using <a href="https://github.com/substack/js-traverse">js-traverse</a>.</p> \
    -        <table> \
    -        <tbody> \
    -        <tr> \
    -          <td colspan="4"> \
    -            <div class="grid-layout layout-tight layout-full"> \
    -              <table rows="4" cols="4"> \
    -              <tbody> \
    -              <tr style="vertical-align: bottom;"> \
    -                <td colspan="4"> \
    -                  Expression \
    -                </td> \
    -              </tr> \
    -              <tr> \
    -                <td colspan="3"> \
    -                  <div class="input-container"> \
    -                    <textarea class="expression-preview-code"></textarea> \
    -                  </div> \
    -                </td> \
    -                <td class="expression-preview-parsing-status" width="150" style="vertical-align: top;"> \
    -                  No syntax error. \
    -                </td> \
    -              </tr> \
    -              <tr> \
    -                <td colspan="4"> \
    -                  <div id="expression-preview-tabs" class="refine-tabs ui-tabs ui-widget ui-widget-content ui-corner-all"> \
    -                    <span>Preview</span> \
    -                    <div id="expression-preview-tabs-preview" class="ui-tabs-panel ui-widget-content ui-corner-bottom"> \
    -                      <div class="expression-preview-container" style="width: 652px; "> \
    -                      </div> \
    -                    </div> \
    -                  </div> \
    -                </td> \
    -              </tr> \
    -              </tbody> \
    -              </table> \
    -            </div> \
    -          </td> \
    -        </tr> \
    -        </tbody> \
    -        </table> \
    -      </div> \
    -    </div> \
    -    <div class="dialog-footer"> \
    -      <button class="okButton button">&nbsp;&nbsp;Update All&nbsp;&nbsp;</button> \
    -      <button class="cancelButton button">Cancel</button> \
    -    </div> \
    -  ',
    -
    -  initialize: function() {
    -    this.el = $(this.el);
    -  },
    -
    -  render: function() {
    -    this.el.html(this.template);
    -  }
    -});

    Graph view for a Dataset using Flot graphing library.

    - -

    Initialization arguments:

    - -
      -
    • model: recline.Model.Dataset
    • -
    • config: (optional) graph configuration hash of form:

      - -

      { - group: {column name for x-axis}, - series: [{column name for series A}, {column name series B}, ... ], - graphType: 'line' - }

    • -
    - -

    NB: should not provide an el argument to the view but must let the view -generate the element itself (you can then append view.el to the DOM.

    my.FlotGraph = Backbone.View.extend({
    -
    -  tagName:  "div",
    -  className: "data-graph-container",
    -
    -  template: ' \
    -  <div class="editor"> \
    -    <div class="editor-info editor-hide-info"> \
    -      <h3 class="action-toggle-help">Help &raquo;</h3> \
    -      <p>To create a chart select a column (group) to use as the x-axis \
    -         then another column (Series A) to plot against it.</p> \
    -      <p>You can add add \
    -         additional series by clicking the "Add series" button</p> \
    -    </div> \
    -    <form class="form-stacked"> \
    -      <div class="clearfix"> \
    -        <label>Graph Type</label> \
    -        <div class="input editor-type"> \
    -          <select> \
    -          <option value="line">Line</option> \
    -          </select> \
    -        </div> \
    -        <label>Group Column (x-axis)</label> \
    -        <div class="input editor-group"> \
    -          <select> \
    -          {{#headers}} \
    -          <option value="{{.}}">{{.}}</option> \
    -          {{/headers}} \
    -          </select> \
    -        </div> \
    -        <div class="editor-series-group"> \
    -          <div class="editor-series"> \
    -            <label>Series <span>A (y-axis)</span></label> \
    -            <div class="input"> \
    -              <select> \
    -              {{#headers}} \
    -              <option value="{{.}}">{{.}}</option> \
    -              {{/headers}} \
    -              </select> \
    -            </div> \
    -          </div> \
    -        </div> \
    -      </div> \
    -      <div class="editor-buttons"> \
    -        <button class="btn editor-add">Add Series</button> \
    -      </div> \
    -      <div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
    -        <button class="editor-save">Save</button> \
    -        <input type="hidden" class="editor-id" value="chart-1" /> \
    -      </div> \
    -    </form> \
    -  </div> \
    -  <div class="panel graph"></div> \
    -</div> \
    -',
    -
    -  events: {
    -    'change form select': 'onEditorSubmit'
    -    , 'click .editor-add': 'addSeries'
    -    , 'click .action-remove-series': 'removeSeries'
    -    , 'click .action-toggle-help': 'toggleHelp'
    -  },
    -
    -  initialize: function(options, config) {
    -    var self = this;
    -    this.el = $(this.el);
    -    _.bindAll(this, 'render', 'redraw');

    we need the model.headers to render properly

        this.model.bind('change', this.render);
    -    this.model.currentDocuments.bind('add', this.redraw);
    -    this.model.currentDocuments.bind('reset', this.redraw);
    -    this.chartConfig = _.extend({
    -        group: null,
    -        series: [],
    -        graphType: 'line'
    -      },
    -      config)
    -    this.render();
    -  },
    -
    -  toTemplateJSON: function() {
    -    return this.model.toJSON();
    -  },
    -
    -  render: function() {
    -    htmls = $.mustache(this.template, this.toTemplateJSON());
    -    $(this.el).html(htmls);

    now set a load of stuff up

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

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

        this.$seriesClone = this.el.find('.editor-series').clone();
    -    this._updateSeries();
    -    return this;
    -  },
    -
    -  onEditorSubmit: function(e) {
    -    var select = this.el.find('.editor-group select');
    -    this._getEditorData();

    update navigation -TODO: make this less invasive (e.g. preserve other keys in query string)

        window.location.hash = window.location.hash.split('?')[0] +
    -        '?graph=' + JSON.stringify(this.chartConfig);
    -    this.redraw();
    -  },
    -
    -  redraw: function() {

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

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

      - -

      Uncaught Invalid dimensions for plot, width = 0, height = 0

    • -
    • There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
    • -
        var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
    -    if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) {
    -      return
    -    }

    create this.plot and cache it

        if (!this.plot) {

    only lines for the present

          options = {
    -        id: 'line',
    -        name: 'Line Chart'
    -      };
    -      this.plot = $.plot(this.$graph, this.createSeries(), options);
    -    } 
    -    this.plot.setData(this.createSeries());
    -    this.plot.resize();
    -    this.plot.setupGrid();
    -    this.plot.draw();
    -  },
    -
    -  _getEditorData: function() {
    -    $editor = this
    -    var series = this.$series.map(function () {
    -      return $(this).val();
    -    });
    -    this.chartConfig.series = $.makeArray(series)
    -    this.chartConfig.group = this.el.find('.editor-group select').val();
    -  },
    -
    -  createSeries: function () {
    -    var self = this;
    -    var series = [];
    -    if (this.chartConfig) {
    -      $.each(this.chartConfig.series, function (seriesIndex, field) {
    -        var points = [];
    -        $.each(self.model.currentDocuments.models, function (index, doc) {
    -          var x = doc.get(self.chartConfig.group);
    -          var y = doc.get(field);
    -          if (typeof x === 'string') {
    -            x = index;
    -          }
    -          points.push([x, y]);
    -        });
    -        series.push({data: points, label: field});
    -      });
    -    }
    -    return series;
    -  },

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

    - -

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

    - -

    Returns itself.

      addSeries: function (e) {
    -    e.preventDefault();
    -    var element = this.$seriesClone.clone(),
    -        label   = element.find('label'),
    -        index   = this.$series.length;
    -
    -    this.el.find('.editor-series-group').append(element);
    -    this._updateSeries();
    -    label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
    -    label.find('span').text(String.fromCharCode(this.$series.length + 64));
    -    return this;
    -  },

    Public: Removes a series list item from the editor.

    - -

    Also updates the labels of the remaining series elements.

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

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

    - -

    Returns itself.

      _updateSeries: function () {
    -    this.$series  = this.el.find('.editor-series select');
    -  }
     });
     
    -return my;
     
    -}(jQuery);
    +/* ========================================================== */

    Miscellaneous Utilities

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

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

    my.parseHashUrl = function(hashUrl) {
    +  var parsed = urlPathRegex.exec(hashUrl);
    +  if (parsed == null) {
    +    return {};
    +  } else {
    +    return {
    +      path: parsed[1],
    +      query: parsed[2] || ''
    +    }
    +  }
    +}

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

    my.parseQueryString = function(q) {
    +  var urlParams = {},
    +    e, d = function (s) {
    +      return unescape(s.replace(/\+/g, " "));
    +    },
    +    r = /([^&=]+)=?([^&]*)/g;
    +
    +  if (q && q.length && q[0] === '?') {
    +    q = q.slice(1);
    +  }
    +  while (e = r.exec(q)) {

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

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

    Parse the query string out of the URL hash

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

    Compse a Query String

    my.composeQueryString = function(queryParams) {
    +  var queryString = '?';
    +  var items = [];
    +  $.each(queryParams, function(key, value) {
    +    items.push(key + '=' + JSON.stringify(value));
    +  });
    +  queryString += items.join('&');
    +  return queryString;
    +}
    +
    +my.setHashQueryString = function(queryParams) {
    +  window.location.hash = window.location.hash.split('?')[0] + my.composeQueryString(queryParams);
    +}

    notify

    + +

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

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

    clearNotifications

    + +

    Clear all existing notifications

    my.clearNotifications = function() {
    +  var $notifications = $('.data-explorer .alert-message');
    +  $notifications.remove();
    +}
    +
    +})(jQuery, recline.View);
     
     
    \ No newline at end of file diff --git a/index.html b/index.html index 4593c718..a69f2f88 100644 --- a/index.html +++ b/index.html @@ -86,8 +86,10 @@
  • Componentized design means you use only what you need
  • + Recline Data Explorer Screenshot +

    Demo

    -

    Demo »

    +

    For Recline Demo Click Here »

    Downloads & Dependencies (Right-click, and use 'Save As')

    Recline Current Version (v0.2) »

    @@ -147,6 +149,7 @@ Backbone.history.start();
  • Models
  • Backends
  • Views including the main Data Explorer
  • +
  • Graph View (based on Flot)
  • Tests

    diff --git a/recline.js b/recline.js index 51a91b0e..c52f5f53 100644 --- a/recline.js +++ b/recline.js @@ -9,13 +9,11 @@ this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; (function($, my) { - my.backends = {}; - // ## Backbone.sync // // Override Backbone.sync to hand off to sync function in relevant backend Backbone.sync = function(method, model, options) { - return my.backends[model.backendConfig.type].sync(method, model, options); + return model.backend.sync(method, model, options); } // ## wrapInTimeout @@ -45,83 +43,96 @@ this.recline.Model = this.recline.Model || {}; // ## BackendMemory - uses in-memory data // - // To use you should: + // This is very artificial and is really only designed for testing + // purposes. + // + // To use it you should provide in your constructor data: // - // A. provide metadata as model data to the Dataset + // * metadata (including fields array) + // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. // - // B. Set backendConfig on your dataset with attributes: - // - // - type: 'memory' - // - data: hash with 2 keys: - // - // * headers: list of header names/labels - // * rows: list of hashes, each hash being one row. A row *must* have an id attribute which is unique. - // - // Example of data: + // Example: // //
    -  //        {
    -  //            headers: ['x', 'y', 'z']
    -  //          , rows: [
    -  //              {id: 0, x: 1, y: 2, z: 3}
    -  //            , {id: 1, x: 2, y: 4, z: 6}
    -  //          ]
    -  //        };
    +  //  // 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({ - sync: function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - if (model.__type__ == 'Dataset') { - var dataset = model; - dataset.set({ - headers: dataset.backendConfig.data.headers - }); - dataset.docCount = dataset.backendConfig.data.rows.length; - dfd.resolve(dataset); - } - return dfd.promise(); - } else if (method === 'update') { - var dfd = $.Deferred(); - if (model.__type__ == 'Document') { - _.each(model.backendConfig.data.rows, function(row, idx) { - if(row.id === model.id) { - model.backendConfig.data.rows[idx] = model.toJSON(); - } - }); - dfd.resolve(model); - } - return dfd.promise(); - } else if (method === 'delete') { - var dfd = $.Deferred(); - if (model.__type__ == 'Document') { - model.backendConfig.data.rows = _.reject(model.backendConfig.data.rows, function(row) { - return (row.id === model.id); - }); - 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; + 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(); - results = model.backendConfig.data.rows; - // not complete sorting! - _.each(queryObj.sort, function(item) { - results = _.sortBy(results, function(row) { - var _out = row[item[0]]; - return (item[1] == 'asc') ? _out : -1*_out; - }); - }); - var results = results.slice(start, start+numRows); - dfd.resolve(results); + 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(); @@ -129,20 +140,12 @@ this.recline.Model = this.recline.Model || {}; // // Connecting to [Webstores](http://github.com/okfn/webstore) // - // To use this backend set backendConfig on your Dataset as: - // - //
    -  // {
    -  //   'type': 'webstore',
    -  //   'url': url to relevant Webstore table
    -  // }
    -  // 
    + // 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 dataset = model; - var base = dataset.backendConfig.url; + var base = model.get('webstore_url'); var schemaUrl = base + '/schema.json'; var jqxhr = $.ajax({ url: schemaUrl, @@ -151,14 +154,14 @@ this.recline.Model = this.recline.Model || {}; }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(schema) { - headers = _.map(schema.data, function(item) { - return item.name; + var fieldData = _.map(schema.data, function(item) { + item.id = item.name; + delete item.name; + return item; }); - dataset.set({ - headers: headers - }); - dataset.docCount = schema.count; - dfd.resolve(dataset, jqxhr); + model.fields.reset(fieldData); + model.docCount = schema.count; + dfd.resolve(model, jqxhr); }) .fail(function(arguments) { dfd.reject(arguments); @@ -168,7 +171,7 @@ this.recline.Model = this.recline.Model || {}; } }, query: function(model, queryObj) { - var base = model.backendConfig.url; + var base = model.get('webstore_url'); var data = { _limit: queryObj.size , _offset: queryObj.offset @@ -193,33 +196,30 @@ this.recline.Model = this.recline.Model || {}; // // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). // - // Set a Dataset to use this backend: - // - // dataset.backendConfig = { - // // required - // url: {url-of-data-to-proxy}, - // format: csv | xls, - // } - // // 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: 'http://jsonpdataproxy.appspot.com' + dataproxy_url: 'http://jsonpdataproxy.appspot.com' }, sync: function(method, model, options) { + var self = this; if (method === "read") { if (model.__type__ == 'Dataset') { - var dataset = model; - var base = my.backends['dataproxy'].get('dataproxy'); + var base = self.get('dataproxy_url'); // TODO: should we cache for extra efficiency var data = { - url: dataset.backendConfig.url + url: model.get('url') , 'max-results': 1 - , type: dataset.backendConfig.format + , type: model.get('format') || 'csv' }; var jqxhr = $.ajax({ url: base @@ -228,10 +228,11 @@ this.recline.Model = this.recline.Model || {}; }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(results) { - dataset.set({ - headers: results.fields - }); - dfd.resolve(dataset, jqxhr); + model.fields.reset(_.map(results.fields, function(fieldId) { + return {id: fieldId}; + }) + ); + dfd.resolve(model, jqxhr); }) .fail(function(arguments) { dfd.reject(arguments); @@ -243,11 +244,11 @@ this.recline.Model = this.recline.Model || {}; } }, query: function(dataset, queryObj) { - var base = my.backends['dataproxy'].get('dataproxy'); + var base = this.get('dataproxy_url'); var data = { - url: dataset.backendConfig.url + url: dataset.get('url') , 'max-results': queryObj.size - , type: dataset.backendConfig.format + , type: dataset.get('format') }; var jqxhr = $.ajax({ url: base @@ -256,10 +257,10 @@ this.recline.Model = this.recline.Model || {}; }); var dfd = $.Deferred(); jqxhr.done(function(results) { - var _out = _.map(results.data, function(row) { + var _out = _.map(results.data, function(doc) { var tmp = {}; _.each(results.fields, function(key, idx) { - tmp[key] = row[idx]; + tmp[key] = doc[idx]; }); return tmp; }); @@ -273,16 +274,31 @@ this.recline.Model = this.recline.Model || {}; // ## Google spreadsheet backend // - // Connect to Google Docs spreadsheet. For write operations + // 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.backendConfig.url, function(d) { - result = my.backends['gdocs'].gdocsToJavascript(d); - model.set({'headers': result.header}); + $.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); @@ -292,9 +308,9 @@ this.recline.Model = this.recline.Model || {}; query: function(dataset, queryObj) { var dfd = $.Deferred(); - var fields = dataset.get('headers'); + var fields = _.pluck(dataset.fields.toJSON(), 'id'); - // zip the field headers with the data rows to produce js objs + // 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 = {}; @@ -307,9 +323,9 @@ this.recline.Model = this.recline.Model || {}; gdocsToJavascript: function(gdocsSpreadsheet) { /* :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by header names) + 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: header and data). + :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. */ @@ -318,7 +334,7 @@ this.recline.Model = this.recline.Model || {}; options = arguments[1]; } var results = { - 'header': [], + 'field': [], 'data': [] }; // default is no special info on type of columns @@ -329,14 +345,14 @@ this.recline.Model = this.recline.Model || {}; // either extract column headings from spreadsheet directly, or used supplied ones if (options.columnsToUse) { // columns set to subset supplied - results.header = options.columnsToUse; + 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.header.push(col); + results.field.push(col); } } } @@ -346,8 +362,8 @@ this.recline.Model = this.recline.Model || {}; var rep = /^([\d\.\-]+)\%$/; $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { var row = []; - for (var k in results.header) { - var col = results.header[k]; + 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 @@ -498,78 +514,125 @@ this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; (function($, my) { - // ## A Dataset model - // - // Other than standard list of Backbone methods it has two important attributes: - // - // * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) - // * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) - my.Dataset = Backbone.Model.extend({ - __type__: 'Dataset', - initialize: function(options) { - this.currentDocuments = new my.DocumentList(); - this.docCount = null; - this.backend = null; - this.defaultQuery = { - size: 100 - , offset: 0 - }; - // this.queryState = {}; - }, - // ### getDocuments - // - // AJAX method with promise API to get rows (documents) from the backend. - // - // Resulting DocumentList are used to reset this.currentDocuments and are - // also returned. - // - // :param numRows: passed onto backend getDocuments. - // :param start: passed onto backend getDocuments. - // - // this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. - // This also illustrates the limitations of separating the Dataset and the Backend - query: function(queryObj) { - var self = this; - var backend = my.backends[this.backendConfig.type]; - this.queryState = queryObj || this.defaultQuery; - this.queryState = _.extend({size: 100, offset: 0}, this.queryState); - var dfd = $.Deferred(); - backend.query(this, this.queryState).done(function(rows) { - var docs = _.map(rows, function(row) { - var _doc = new my.Document(row); - _doc.backendConfig = self.backendConfig; - _doc.backend = backend; - return _doc; - }); - self.currentDocuments.reset(docs); - dfd.resolve(self.currentDocuments); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - }, - - toTemplateJSON: function() { - var data = this.toJSON(); - data.docCount = this.docCount; - return data; +// ## A Dataset model +// +// A model must have the following (Backbone) attributes: +// +// * fields: (aka columns) is a FieldList listing all the fields on this +// Dataset (this can be set explicitly, or, on fetch() of Dataset +// information from the backend, or as is perhaps most common on the first +// query) +// * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) +// * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) +my.Dataset = Backbone.Model.extend({ + __type__: 'Dataset', + initialize: function(model, backend) { + _.bindAll(this, 'query'); + this.backend = backend; + if (backend && backend.constructor == String) { + this.backend = my.backends[backend]; } - }); + this.fields = new my.FieldList(); + this.currentDocuments = new my.DocumentList(); + this.docCount = null; + this.queryState = new my.Query(); + this.queryState.bind('change', this.query); + }, - // ## A Document (aka Row) - // - // A single entry or row in the dataset - my.Document = Backbone.Model.extend({ - __type__: 'Document' - }); + // ### query + // + // AJAX method with promise API to get documents from the backend. + // + // It will query based on current query state (given by this.queryState) + // updated by queryObj (if provided). + // + // Resulting DocumentList are used to reset this.currentDocuments and are + // also returned. + query: function(queryObj) { + var self = this; + this.queryState.set(queryObj, {silent: true}); + var dfd = $.Deferred(); + this.backend.query(this, this.queryState.toJSON()).done(function(rows) { + var docs = _.map(rows, function(row) { + var _doc = new my.Document(row); + _doc.backend = self.backend; + _doc.dataset = self; + return _doc; + }); + self.currentDocuments.reset(docs); + dfd.resolve(self.currentDocuments); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }, + + toTemplateJSON: function() { + var data = this.toJSON(); + data.docCount = this.docCount; + data.fields = this.fields.toJSON(); + return data; + } +}); + +// ## A Document (aka Row) +// +// A single entry or row in the dataset +my.Document = Backbone.Model.extend({ + __type__: 'Document' +}); + +// ## A Backbone collection of Documents +my.DocumentList = Backbone.Collection.extend({ + __type__: 'DocumentList', + model: my.Document +}); + +// ## A Field (aka Column) on a Dataset +// +// Following attributes as standard: +// +// * id: a unique identifer for this field- usually this should match the key in the documents hash +// * label: the visible label used for this field +// * type: the type of the data +my.Field = Backbone.Model.extend({ + defaults: { + id: null, + label: null, + type: 'String' + }, + // In addition to normal backbone initialization via a Hash you can also + // just pass a single argument representing id to the ctor + initialize: function(data) { + // if a hash not passed in the first argument is set as value for key 0 + if ('0' in data) { + throw new Error('Looks like you did not pass a proper hash with id to Field constructor'); + } + if (this.attributes.label == null) { + this.set({label: this.id}); + } + } +}); + +my.FieldList = Backbone.Collection.extend({ + model: my.Field +}); + +// ## A Query object storing Dataset Query state +my.Query = Backbone.Model.extend({ + defaults: { + size: 100 + , offset: 0 + } +}); + +// ## Backend registry +// +// Backends will register themselves by id into this registry +my.backends = {}; - // ## A Backbone collection of Documents - my.DocumentList = Backbone.Collection.extend({ - __type__: 'DocumentList', - model: my.Document - }); }(jQuery, this.recline.Model)); var util = function() { @@ -585,7 +648,7 @@ var util = function() { , rowActions: '
  • Delete this row
  • ' , rootActions: ' \ {{#columns}} \ -
  • Add column: {{.}}
  • \ +
  • Show column: {{.}}
  • \ {{/columns}}' , cellEditor: ' \ \ +', + + events: { + 'change form select': 'onEditorSubmit' + , 'click .editor-add': 'addSeries' + , 'click .action-remove-series': 'removeSeries' + , 'click .action-toggle-help': 'toggleHelp' + }, + + initialize: function(options, config) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render', 'redraw'); + // we need the model.fields to render properly + this.model.bind('change', this.render); + this.model.fields.bind('reset', this.render); + this.model.fields.bind('add', this.render); + this.model.currentDocuments.bind('add', this.redraw); + this.model.currentDocuments.bind('reset', this.redraw); + var configFromHash = my.parseHashQueryString().graph; + if (configFromHash) { + configFromHash = JSON.parse(configFromHash); + } + this.chartConfig = _.extend({ + group: null, + series: [], + graphType: 'line' + }, + configFromHash, + config + ); + this.render(); + }, + + render: function() { + htmls = $.mustache(this.template, this.model.toTemplateJSON()); + $(this.el).html(htmls); + // now set a load of stuff up + this.$graph = this.el.find('.panel.graph'); + // for use later when adding additional series + // could be simpler just to have a common template! + this.$seriesClone = this.el.find('.editor-series').clone(); + this._updateSeries(); + return this; + }, + + onEditorSubmit: function(e) { + var select = this.el.find('.editor-group select'); + this._getEditorData(); + // update navigation + // TODO: make this less invasive (e.g. preserve other keys in query string) + var qs = my.parseHashQueryString(); + qs['graph'] = this.chartConfig; + my.setHashQueryString(qs); + this.redraw(); + }, + + redraw: function() { + // There appear to be issues generating a Flot graph if either: + + // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with + // + // Uncaught Invalid dimensions for plot, width = 0, height = 0 + // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' + var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); + if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { + return + } + // create this.plot and cache it + if (!this.plot) { + // only lines for the present + options = { + id: 'line', + name: 'Line Chart' + }; + this.plot = $.plot(this.$graph, this.createSeries(), options); + } + this.plot.setData(this.createSeries()); + this.plot.resize(); + this.plot.setupGrid(); + this.plot.draw(); + }, + + _getEditorData: function() { + $editor = this + var series = this.$series.map(function () { + return $(this).val(); + }); + this.chartConfig.series = $.makeArray(series) + this.chartConfig.group = this.el.find('.editor-group select').val(); + }, + + createSeries: function () { + var self = this; + var series = []; + if (this.chartConfig) { + $.each(this.chartConfig.series, function (seriesIndex, field) { + var points = []; + $.each(self.model.currentDocuments.models, function (index, doc) { + var x = doc.get(self.chartConfig.group); + var y = doc.get(field); + if (typeof x === 'string') { + x = index; + } + points.push([x, y]); + }); + series.push({data: points, label: field}); }); - }, 1000); + } + return series; + }, + + // Public: Adds a new empty series select box to the editor. + // + // All but the first select box will have a remove button that allows them + // to be removed. + // + // Returns itself. + addSeries: function (e) { + e.preventDefault(); + var element = this.$seriesClone.clone(), + label = element.find('label'), + index = this.$series.length; + + this.el.find('.editor-series-group').append(element); + this._updateSeries(); + label.append(' [Remove]'); + label.find('span').text(String.fromCharCode(this.$series.length + 64)); + return this; + }, + + // Public: Removes a series list item from the editor. + // + // Also updates the labels of the remaining series elements. + removeSeries: function (e) { + e.preventDefault(); + var $el = $(e.target); + $el.parent().parent().remove(); + this._updateSeries(); + this.$series.each(function (index) { + if (index > 0) { + var labelSpan = $(this).prev().find('span'); + labelSpan.text(String.fromCharCode(index + 65)); + } + }); + this.onEditorSubmit(); + }, + + toggleHelp: function() { + this.el.find('.editor-info').toggleClass('editor-hide-info'); + }, + + // Private: Resets the series property to reference the select elements. + // + // Returns itself. + _updateSeries: function () { + this.$series = this.el.find('.editor-series select'); } -} +}); -// ## clearNotifications -// -// Clear all existing notifications -my.clearNotifications = function() { - var $notifications = $('.data-explorer .alert-message'); - $notifications.remove(); -} +})(jQuery, recline.View); -// The primary view for the entire application. +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { +// ## DataExplorer // -// It should be initialized with a recline.Model.Dataset object and an existing -// dom element to attach to (the existing DOM element is important for -// rendering of FlotGraph subview). +// The primary view for the entire application. Usage: // -// To pass in configuration options use the config key in initialization hash -// e.g. +//
    +// var myExplorer = new model.recline.DataExplorer({
    +//   model: {{recline.Model.Dataset instance}}
    +//   el: {{an existing dom element}}
    +//   views: {{page views}}
    +//   config: {{config options -- see below}}
    +// });
    +// 
    // -// var explorer = new DataExplorer({ -// config: {...} -// }) +// ### Parameters +// +// **model**: (required) Dataset instance. // -// Config options: +// **el**: (required) DOM element. // -// * 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). +// **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: // -// All other views as contained in this one. +//
    +// 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: ' \
    \ @@ -827,8 +1095,9 @@ my.DataExplorer = Backbone.View.extend({ \
    \ \ \ \ {{/notEmpty}} \ - {{#headers}} \ -
    \ + {{#fields}} \ + \
    \ \ - {{.}} \ + {{label}} \
    \ \
    \ + \
    \   \
    {{value}}
    \ @@ -1197,8 +1468,8 @@ my.DataTableRow = Backbone.View.extend({ toTemplateJSON: function() { var doc = this.model; - var cellData = _.map(this._headers, function(header) { - return {header: header, value: doc.get(header)} + var cellData = this._fields.map(function(field) { + return {field: field.id, value: doc.get(field.id)} }) return { id: this.id, cells: cellData } }, @@ -1210,8 +1481,8 @@ my.DataTableRow = Backbone.View.extend({ return this; }, - // ====================================================== // Cell Editor + // =========== onEditClick: function(e) { var editing = this.el.find('.data-table-cell-editor-editor'); @@ -1227,10 +1498,10 @@ my.DataTableRow = Backbone.View.extend({ onEditorOK: function(e) { var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); - var header = cell.parents('td').attr('data-header'); + 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[header] = newValue; + newData[field] = newValue; this.model.set(newData); my.notify("Updating row...", {loader: true}); this.model.save().then(function(response) { @@ -1251,6 +1522,180 @@ my.DataTableRow = Backbone.View.extend({ }); +/* ========================================================== */ +// ## Miscellaneous Utilities + +var urlPathRegex = /^([^?]+)(\?.*)?/; + +// Parse the Hash section of a URL into path and query string +my.parseHashUrl = function(hashUrl) { + var parsed = urlPathRegex.exec(hashUrl); + if (parsed == null) { + return {}; + } else { + return { + path: parsed[1], + query: parsed[2] || '' + } + } +} + +// Parse a URL query string (?xyz=abc...) into a dictionary. +my.parseQueryString = function(q) { + var urlParams = {}, + e, d = function (s) { + return unescape(s.replace(/\+/g, " ")); + }, + r = /([^&=]+)=?([^&]*)/g; + + if (q && q.length && q[0] === '?') { + q = q.slice(1); + } + while (e = r.exec(q)) { + // TODO: have values be array as query string allow repetition of keys + urlParams[d(e[1])] = d(e[2]); + } + return urlParams; +} + +// Parse the query string out of the URL hash +my.parseHashQueryString = function() { + q = my.parseHashUrl(window.location.hash).query; + return my.parseQueryString(q); +} + +// Compse a Query String +my.composeQueryString = function(queryParams) { + var queryString = '?'; + var items = []; + $.each(queryParams, function(key, value) { + items.push(key + '=' + JSON.stringify(value)); + }); + queryString += items.join('&'); + return queryString; +} + +my.setHashQueryString = function(queryParams) { + window.location.hash = window.location.hash.split('?')[0] + my.composeQueryString(queryParams); +} + +// ## notify +// +// Create a notification (a div.alert-message in div.alert-messsages) using provide messages and options. Options are: +// +// * category: warning (default), success, error +// * persist: if true alert is persistent, o/w hidden after 3s (default = false) +// * loader: if true show loading spinner +my.notify = function(message, options) { + if (!options) var options = {}; + var tmplData = _.extend({ + msg: message, + category: 'warning' + }, + options); + var _template = ' \ +
    × \ +

    {{msg}} \ + {{#loader}} \ + \ + {{/loader}} \ +

    \ +
    '; + var _templated = $.mustache(_template, tmplData); + _templated = $(_templated).appendTo($('.data-explorer .alert-messages')); + if (!options.persist) { + setTimeout(function() { + $(_templated).fadeOut(1000, function() { + $(this).remove(); + }); + }, 1000); + } +} + +// ## clearNotifications +// +// Clear all existing notifications +my.clearNotifications = function() { + var $notifications = $('.data-explorer .alert-message'); + $notifications.remove(); +} + +})(jQuery, recline.View); + +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +// Views module following classic module pattern +(function($, my) { + +// View (Dialog) for doing data transformations on whole dataset. +my.DataTransform = Backbone.View.extend({ + className: 'transform-view', + template: ' \ +
    \ + Recursive transform on all rows \ +
    \ +
    \ +
    \ +

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

    \ + \ + \ + \ + \ + \ + \ +
    \ +
    \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
    \ + Expression \ +
    \ +
    \ + \ +
    \ +
    \ + No syntax error. \ +
    \ +
    \ + Preview \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    \ + \ + ', + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + this.el.html(this.template); + } +}); + + // View (Dialog) for doing data transformations (on columns of data). my.ColumnTransform = Backbone.View.extend({ className: 'transform-column-view', @@ -1382,299 +1827,4 @@ my.ColumnTransform = Backbone.View.extend({ } }); -// View (Dialog) for doing data transformations on whole dataset. -my.DataTransform = Backbone.View.extend({ - className: 'transform-view', - template: ' \ -
    \ - Recursive transform on all rows \ -
    \ -
    \ -
    \ -

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

    \ - \ - \ - \ - \ - \ - \ -
    \ -
    \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ -
    \ - Expression \ -
    \ -
    \ - \ -
    \ -
    \ - No syntax error. \ -
    \ -
    \ - Preview \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ - \ - ', - - initialize: function() { - this.el = $(this.el); - }, - - render: function() { - this.el.html(this.template); - } -}); - - -// Graph view for a Dataset using Flot graphing library. -// -// Initialization arguments: -// -// * model: recline.Model.Dataset -// * config: (optional) graph configuration hash of form: -// -// { -// group: {column name for x-axis}, -// series: [{column name for series A}, {column name series B}, ... ], -// graphType: 'line' -// } -// -// NB: should *not* provide an el argument to the view but must let the view -// generate the element itself (you can then append view.el to the DOM. -my.FlotGraph = Backbone.View.extend({ - - tagName: "div", - className: "data-graph-container", - - template: ' \ -
    \ -
    \ -

    Help »

    \ -

    To create a chart select a column (group) to use as the x-axis \ - then another column (Series A) to plot against it.

    \ -

    You can add add \ - additional series by clicking the "Add series" button

    \ -
    \ - \ -
    \ - \ -
    \ - \ -
    \ - \ -
    \ - \ -
    \ -
    \ -
    \ - \ -
    \ - \ -
    \ -
    \ -
    \ -
    \ -
    \ - \ -
    \ - \ - \ -
    \ -
    \ -
    \ -', - - events: { - 'change form select': 'onEditorSubmit' - , 'click .editor-add': 'addSeries' - , 'click .action-remove-series': 'removeSeries' - , 'click .action-toggle-help': 'toggleHelp' - }, - - initialize: function(options, config) { - var self = this; - this.el = $(this.el); - _.bindAll(this, 'render', 'redraw'); - // we need the model.headers to render properly - this.model.bind('change', this.render); - this.model.currentDocuments.bind('add', this.redraw); - this.model.currentDocuments.bind('reset', this.redraw); - this.chartConfig = _.extend({ - group: null, - series: [], - graphType: 'line' - }, - config) - this.render(); - }, - - toTemplateJSON: function() { - return this.model.toJSON(); - }, - - render: function() { - htmls = $.mustache(this.template, this.toTemplateJSON()); - $(this.el).html(htmls); - // now set a load of stuff up - this.$graph = this.el.find('.panel.graph'); - // for use later when adding additional series - // could be simpler just to have a common template! - this.$seriesClone = this.el.find('.editor-series').clone(); - this._updateSeries(); - return this; - }, - - onEditorSubmit: function(e) { - var select = this.el.find('.editor-group select'); - this._getEditorData(); - // update navigation - // TODO: make this less invasive (e.g. preserve other keys in query string) - window.location.hash = window.location.hash.split('?')[0] + - '?graph=' + JSON.stringify(this.chartConfig); - this.redraw(); - }, - - redraw: function() { - // There appear to be issues generating a Flot graph if either: - - // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with - // - // Uncaught Invalid dimensions for plot, width = 0, height = 0 - // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' - var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); - if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { - return - } - // create this.plot and cache it - if (!this.plot) { - // only lines for the present - options = { - id: 'line', - name: 'Line Chart' - }; - this.plot = $.plot(this.$graph, this.createSeries(), options); - } - this.plot.setData(this.createSeries()); - this.plot.resize(); - this.plot.setupGrid(); - this.plot.draw(); - }, - - _getEditorData: function() { - $editor = this - var series = this.$series.map(function () { - return $(this).val(); - }); - this.chartConfig.series = $.makeArray(series) - this.chartConfig.group = this.el.find('.editor-group select').val(); - }, - - createSeries: function () { - var self = this; - var series = []; - if (this.chartConfig) { - $.each(this.chartConfig.series, function (seriesIndex, field) { - var points = []; - $.each(self.model.currentDocuments.models, function (index, doc) { - var x = doc.get(self.chartConfig.group); - var y = doc.get(field); - if (typeof x === 'string') { - x = index; - } - points.push([x, y]); - }); - series.push({data: points, label: field}); - }); - } - return series; - }, - - // Public: Adds a new empty series select box to the editor. - // - // All but the first select box will have a remove button that allows them - // to be removed. - // - // Returns itself. - addSeries: function (e) { - e.preventDefault(); - var element = this.$seriesClone.clone(), - label = element.find('label'), - index = this.$series.length; - - this.el.find('.editor-series-group').append(element); - this._updateSeries(); - label.append(' [Remove]'); - label.find('span').text(String.fromCharCode(this.$series.length + 64)); - return this; - }, - - // Public: Removes a series list item from the editor. - // - // Also updates the labels of the remaining series elements. - removeSeries: function (e) { - e.preventDefault(); - var $el = $(e.target); - $el.parent().parent().remove(); - this._updateSeries(); - this.$series.each(function (index) { - if (index > 0) { - var labelSpan = $(this).prev().find('span'); - labelSpan.text(String.fromCharCode(index + 65)); - } - }); - this.onEditorSubmit(); - }, - - toggleHelp: function() { - this.el.find('.editor-info').toggleClass('editor-hide-info'); - }, - - // Private: Resets the series property to reference the select elements. - // - // Returns itself. - _updateSeries: function () { - this.$series = this.el.find('.editor-series select'); - } -}); - -return my; - -}(jQuery); - +})(jQuery, recline.View); diff --git a/src/backend.js b/src/backend.js index 98dff9da..f723aac8 100644 --- a/src/backend.js +++ b/src/backend.js @@ -48,7 +48,7 @@ this.recline.Model = this.recline.Model || {}; // // To use it you should provide in your constructor data: // - // * metadata (including headers array) + // * metadata (including fields array) // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique. // // Example: @@ -59,9 +59,9 @@ this.recline.Model = this.recline.Model || {}; // backend.addDataset({ // metadata: { // id: 'my-id', - // title: 'My Title', - // headers: ['x', 'y', 'z'], + // 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} @@ -86,6 +86,7 @@ this.recline.Model = this.recline.Model || {}; 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); } @@ -153,12 +154,12 @@ this.recline.Model = this.recline.Model || {}; }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(schema) { - headers = _.map(schema.data, function(item) { - return item.name; - }); - model.set({ - headers: headers + 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); }) @@ -227,9 +228,10 @@ this.recline.Model = this.recline.Model || {}; }); var dfd = $.Deferred(); wrapInTimeout(jqxhr).done(function(results) { - model.set({ - headers: results.fields - }); + model.fields.reset(_.map(results.fields, function(fieldId) { + return {id: fieldId}; + }) + ); dfd.resolve(model, jqxhr); }) .fail(function(arguments) { @@ -293,7 +295,10 @@ this.recline.Model = this.recline.Model || {}; $.getJSON(model.get('url'), function(d) { result = self.gdocsToJavascript(d); - model.set({'headers': result.header}); + 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); @@ -303,9 +308,9 @@ this.recline.Model = this.recline.Model || {}; query: function(dataset, queryObj) { var dfd = $.Deferred(); - var fields = dataset.get('headers'); + var fields = _.pluck(dataset.fields.toJSON(), 'id'); - // zip the field headers with the data rows to produce js objs + // 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 = {}; @@ -318,9 +323,9 @@ this.recline.Model = this.recline.Model || {}; gdocsToJavascript: function(gdocsSpreadsheet) { /* :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by header names) + 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: header and data). + :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. */ @@ -329,7 +334,7 @@ this.recline.Model = this.recline.Model || {}; options = arguments[1]; } var results = { - 'header': [], + 'field': [], 'data': [] }; // default is no special info on type of columns @@ -340,14 +345,14 @@ this.recline.Model = this.recline.Model || {}; // either extract column headings from spreadsheet directly, or used supplied ones if (options.columnsToUse) { // columns set to subset supplied - results.header = options.columnsToUse; + 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.header.push(col); + results.field.push(col); } } } @@ -357,8 +362,8 @@ this.recline.Model = this.recline.Model || {}; var rep = /^([\d\.\-]+)\%$/; $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { var row = []; - for (var k in results.header) { - var col = results.header[k]; + 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 diff --git a/src/model.js b/src/model.js index 034b196a..932902d9 100644 --- a/src/model.js +++ b/src/model.js @@ -3,85 +3,124 @@ this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; (function($, my) { - // ## A Dataset model - // - // Other than standard list of Backbone methods it has two important attributes: - // - // * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) - // * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) - my.Dataset = Backbone.Model.extend({ - __type__: 'Dataset', - initialize: function(model, backend) { - this.backend = backend; - if (backend && backend.constructor == String) { - this.backend = my.backends[backend]; - } - this.currentDocuments = new my.DocumentList(); - this.docCount = null; - this.defaultQuery = { - size: 100 - , offset: 0 - }; - // this.queryState = {}; - }, - // ### getDocuments - // - // AJAX method with promise API to get rows (documents) from the backend. - // - // Resulting DocumentList are used to reset this.currentDocuments and are - // also returned. - // - // :param numRows: passed onto backend getDocuments. - // :param start: passed onto backend getDocuments. - // - // this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. - // This also illustrates the limitations of separating the Dataset and the Backend - query: function(queryObj) { - var self = this; - this.queryState = queryObj || this.defaultQuery; - this.queryState = _.extend({size: 100, offset: 0}, this.queryState); - var dfd = $.Deferred(); - this.backend.query(this, this.queryState).done(function(rows) { - var docs = _.map(rows, function(row) { - var _doc = new my.Document(row); - _doc.backend = self.backend; - _doc.dataset = self; - return _doc; - }); - self.currentDocuments.reset(docs); - dfd.resolve(self.currentDocuments); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - }, - - toTemplateJSON: function() { - var data = this.toJSON(); - data.docCount = this.docCount; - return data; +// ## A Dataset model +// +// A model must have the following (Backbone) attributes: +// +// * fields: (aka columns) is a FieldList listing all the fields on this +// Dataset (this can be set explicitly, or, on fetch() of Dataset +// information from the backend, or as is perhaps most common on the first +// query) +// * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) +// * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) +my.Dataset = Backbone.Model.extend({ + __type__: 'Dataset', + initialize: function(model, backend) { + _.bindAll(this, 'query'); + this.backend = backend; + if (backend && backend.constructor == String) { + this.backend = my.backends[backend]; } - }); + this.fields = new my.FieldList(); + this.currentDocuments = new my.DocumentList(); + this.docCount = null; + this.queryState = new my.Query(); + this.queryState.bind('change', this.query); + }, - // ## A Document (aka Row) - // - // A single entry or row in the dataset - my.Document = Backbone.Model.extend({ - __type__: 'Document' - }); - - // ## A Backbone collection of Documents - my.DocumentList = Backbone.Collection.extend({ - __type__: 'DocumentList', - model: my.Document - }); - - // ## Backend registry + // ### query // - // Backends will register themselves by id into this registry - my.backends = {}; + // AJAX method with promise API to get documents from the backend. + // + // It will query based on current query state (given by this.queryState) + // updated by queryObj (if provided). + // + // Resulting DocumentList are used to reset this.currentDocuments and are + // also returned. + query: function(queryObj) { + var self = this; + this.queryState.set(queryObj, {silent: true}); + var dfd = $.Deferred(); + this.backend.query(this, this.queryState.toJSON()).done(function(rows) { + var docs = _.map(rows, function(row) { + var _doc = new my.Document(row); + _doc.backend = self.backend; + _doc.dataset = self; + return _doc; + }); + self.currentDocuments.reset(docs); + dfd.resolve(self.currentDocuments); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }, + + toTemplateJSON: function() { + var data = this.toJSON(); + data.docCount = this.docCount; + data.fields = this.fields.toJSON(); + return data; + } +}); + +// ## A Document (aka Row) +// +// A single entry or row in the dataset +my.Document = Backbone.Model.extend({ + __type__: 'Document' +}); + +// ## A Backbone collection of Documents +my.DocumentList = Backbone.Collection.extend({ + __type__: 'DocumentList', + model: my.Document +}); + +// ## A Field (aka Column) on a Dataset +// +// Following attributes as standard: +// +// * id: a unique identifer for this field- usually this should match the key in the documents hash +// * label: the visible label used for this field +// * type: the type of the data +my.Field = Backbone.Model.extend({ + defaults: { + id: null, + label: null, + type: 'String' + }, + // In addition to normal backbone initialization via a Hash you can also + // just pass a single argument representing id to the ctor + initialize: function(data) { + // if a hash not passed in the first argument is set as value for key 0 + if ('0' in data) { + throw new Error('Looks like you did not pass a proper hash with id to Field constructor'); + } + if (this.attributes.label == null) { + this.set({label: this.id}); + } + } +}); + +my.FieldList = Backbone.Collection.extend({ + model: my.Field +}); + +// ## A Query object storing Dataset Query state +my.Query = Backbone.Model.extend({ + defaults: { + size: 100 + , offset: 0 + } +}); + +// ## Backend registry +// +// Backends will register themselves by id into this registry +my.backends = {}; }(jQuery, this.recline.Model)); diff --git a/src/view-data-explorer.js b/src/view-data-explorer.js deleted file mode 100644 index 7e3c2258..00000000 --- a/src/view-data-explorer.js +++ /dev/null @@ -1,171 +0,0 @@ -this.recline = this.recline || {}; -this.recline.View = this.recline.View || {}; - -// Views module following classic module pattern -(function($, my) { - -// The primary view for the entire application. -// -// It should be initialized with a recline.Model.Dataset object and an existing -// dom element to attach to (the existing DOM element is important for -// rendering of FlotGraph subview). -// -// To pass in configuration options use the config key in initialization hash -// e.g. -// -// var explorer = new DataExplorer({ -// config: {...} -// }) -// -// Config options: -// -// * 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). -// -// All other views as contained in this one. -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 - this.pageViews = { - grid: new my.DataTable({ - model: this.model, - config: this.config - }) - , graph: new my.FlotGraph({ - model: this.model - }) - }; - // this must be called after pageViews are created - this.render(); - - this.router = new Backbone.Router(); - this.setupRouting(); - - // retrieve basic data like headers 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; - 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.el) - }); - }, - - setupRouting: function() { - var self = this; - this.router.route('', 'grid', function() { - self.updateNav('grid'); - }); - this.router.route(/grid(\?.*)?/, 'view', function(queryString) { - self.updateNav('grid', queryString); - }); - this.router.route(/graph(\?.*)?/, 'graph', function(queryString) { - self.updateNav('graph', queryString); - // we have to call here due to fact plot may not have been able to draw - // if it was hidden until now - see comments in FlotGraph.redraw - qsParsed = parseQueryString(queryString); - if ('graph' in qsParsed) { - var chartConfig = JSON.parse(qsParsed['graph']); - _.extend(self.pageViews['graph'].chartConfig, chartConfig); - } - self.pageViews['graph'].redraw(); - }); - }, - - 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, pageViewName) { - if (pageViewName === pageName) { - view.el.show(); - } else { - view.el.hide(); - } - }); - } -}); - -})(jQuery, recline.View); - - diff --git a/src/view-data-table.js b/src/view-data-table.js deleted file mode 100644 index ff4a8c26..00000000 --- a/src/view-data-table.js +++ /dev/null @@ -1,298 +0,0 @@ -this.recline = this.recline || {}; -this.recline.View = this.recline.View || {}; - -// Views module following classic module pattern -(function($, my) { - -// 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.hiddenHeaders = []; - }, - - 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).siblings().text(); - 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.hiddenHeaders}); - }, - - 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 query = _.extend(this.model.queryState, {sort: [[this.state.currentColumn, order]]}); - this.model.query(query); - }, - - hideColumn: function() { - this.hiddenHeaders.push(this.state.currentColumn); - this.render(); - }, - - showColumn: function(e) { - this.hiddenHeaders = _.without(this.hiddenHeaders, $(e.target).data('column')); - this.render(); - }, - - // ====================================================== - // Core Templating - template: ' \ - \ -
      \ - \ - \ - \ - {{#notEmpty}} \ - \ - {{/notEmpty}} \ - {{#headers}} \ - \ - {{/headers}} \ - \ - \ - \ -
      \ -
      \ - \ - \ -
      \ -
      \ -
      \ - \ - {{.}} \ -
      \ - \ -
      \ - ', - - toTemplateJSON: function() { - var modelData = this.model.toJSON() - modelData.notEmpty = ( this.headers.length > 0 ) - modelData.headers = this.headers; - return modelData; - }, - render: function() { - var self = this; - this.headers = _.filter(this.model.get('headers'), function(header) { - return _.indexOf(self.hiddenHeaders, header) == -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; + }, + 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}}
      \ +
      \ +