From 53e099beda3d41ea21dc906f3cd5a3beff0dc582 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 16 Apr 2012 15:15:59 +0100 Subject: [PATCH 01/20] [test][xs]: make fixtures dataset same as for demo. --- test/base.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/base.js b/test/base.js index bffbba1c..729e1178 100644 --- a/test/base.js +++ b/test/base.js @@ -1,13 +1,21 @@ var Fixture = { getDataset: function() { - var fields = [{id: 'x'}, {id: 'y'}, {id: 'z'}, {id: 'country'}, {id: 'label'},{id: 'lat'},{id: 'lon'}]; + var fields = [ + {id: 'x'}, + {id: 'y'}, + {id: 'z'}, + {id: 'country'}, + {id: 'label'}, + {id: 'lat'}, + {id: 'lon'} + ]; var documents = [ - {id: 0, x: 1, y: 2, z: 3, country: 'DE', lat:52.56, lon:13.40} - , {id: 1, x: 2, y: 4, z: 6, country: 'UK', lat:54.97, lon:-1.60} - , {id: 2, x: 3, y: 6, z: 9, country: 'US', lat:40.00, lon:-75.5} - , {id: 3, x: 4, y: 8, z: 12, country: 'UK', lat:57.27, lon:-6.20} - , {id: 4, x: 5, y: 10, z: 15, country: 'UK', lat:51.58, lon:0} - , {id: 5, x: 6, y: 12, z: 18, country: 'DE', lat:51.04, lon:7.9} + {id: 0, x: 1, y: 2, z: 3, country: 'DE', label: 'first', lat:52.56, lon:13.40}, + {id: 1, x: 2, y: 4, z: 6, country: 'UK', label: 'second', lat:54.97, lon:-1.60}, + {id: 2, x: 3, y: 6, z: 9, country: 'US', label: 'third', lat:40.00, lon:-75.5}, + {id: 3, x: 4, y: 8, z: 12, country: 'UK', label: 'fourth', lat:57.27, lon:-6.20}, + {id: 4, x: 5, y: 10, z: 15, country: 'UK', label: 'fifth', lat:51.58, lon:0}, + {id: 5, x: 6, y: 12, z: 18, country: 'DE', label: 'sixth', lat:51.04, lon:7.9} ]; var dataset = recline.Backend.createDataset(documents, fields); return dataset; From a42840cdf3231a050a784d7104d5786818fdfa76 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 16 Apr 2012 15:16:28 +0100 Subject: [PATCH 02/20] [#88,model/state][xs]: can just specify a url instead of full dataset object when restoring from state (easier and good for backwards compatability). --- src/model.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/model.js b/src/model.js index 1464ec46..4145507b 100644 --- a/src/model.js +++ b/src/model.js @@ -150,17 +150,22 @@ my.Dataset = Backbone.Model.extend({ //
 // {
 //   backend: {backend type - i.e. value of dataset.backend.__type__}
-//   dataset: {result of dataset.toJSON()}
+//   dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler }
+//   // convenience - if url provided and dataste not this be used as dataset url
+//   url: {dataset url}
 //   ...
 // }
 my.Dataset.restore = function(state) {
   // hack-y - restoring a memory dataset does not mean much ...
   var dataset = null;
+  if (state.url && !state.dataset) {
+    state.dataset = {url: state.url};
+  }
   if (state.backend === 'memory') {
     dataset = recline.Backend.createDataset(
       [{stub: 'this is a stub dataset because we do not restore memory datasets'}],
       [],
-      state.dataset
+      state.dataset // metadata
     );
   } else {
     dataset = new recline.Model.Dataset(

From 0f0fa633a1d8f31e1bf1662d7b6d310f6c32805f Mon Sep 17 00:00:00 2001
From: Rufus Pollock 
Date: Mon, 16 Apr 2012 15:17:19 +0100
Subject: [PATCH 03/20] [#67,app][s]: first pass at sharable link support in
 app (though seems rather buggy).

---
 app/index.html | 17 +++++++++++++++
 app/js/app.js  | 56 +++++++++++++++++++++++++++++++++-----------------
 src/view.js    |  3 +++
 3 files changed, 57 insertions(+), 19 deletions(-)

diff --git a/app/index.html b/app/index.html
index 29ff9781..cfd9492e 100644
--- a/app/index.html
+++ b/app/index.html
@@ -81,6 +81,12 @@
               
             
           
+          
  • + + Share and Embed + + +
  • @@ -148,6 +154,17 @@ + +
    diff --git a/app/js/app.js b/app/js/app.js index 956e6f53..ab6f4dc4 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,20 +1,5 @@ jQuery(function($) { - var qs = recline.View.parseQueryString(window.location.search); - var dataest = null; - if (qs.url) { - dataset = new recline.Model.Dataset({ - id: 'my-dataset', - url: qs.url, - webstore_url: qs.url - }, - qs.backend || 'elasticsearch' - ); - } else { - dataset = localDataset(); - } - var app = new ExplorerApp({ - model: dataset, el: $('.recline-app') }) Backbone.history.start(); @@ -27,14 +12,32 @@ var ExplorerApp = Backbone.View.extend({ }, initialize: function() { + this.el = $(this.el); this.explorer = null; this.explorerDiv = $('.data-explorer-here'); - this.createExplorer(this.model); + + var state = recline.View.parseQueryString(window.location.search); + if (state) { + _.each(state, function(value, key) { + try { + value = JSON.parse(value); + } catch(e) {} + state[key] = value; + }); + } + var dataset = null; + if (state.dataset || state.url) { + dataset = recline.Model.Dataset.restore(state); + } else { + dataset = localDataset(); + } + this.createExplorer(dataset, state); }, // make Explorer creation / initialization in a function so we can call it // again and again - createExplorer: function(dataset) { + createExplorer: function(dataset, state) { + var self = this; // remove existing data explorer view var reload = false; if (this.dataExplorer) { @@ -45,9 +48,12 @@ var ExplorerApp = Backbone.View.extend({ var $el = $('
    '); $el.appendTo(this.explorerDiv); this.dataExplorer = new recline.View.DataExplorer({ - el: $el - , model: dataset + model: dataset, + el: $el, + state: state }); + this._setupPermaLink(this.dataExplorer); + // HACK (a bit). Issue is that Backbone will not trigger the route // if you are already at that location so we have to make sure we genuinely switch if (reload) { @@ -56,6 +62,18 @@ var ExplorerApp = Backbone.View.extend({ } }, + _setupPermaLink: function(explorer) { + var $viewLink = this.el.find('.js-share-and-embed-dialog .view-link'); + function makePermaLink(state) { + var qs = recline.View.composeQueryString(state.toJSON()); + return window.location.origin + window.location.pathname + qs; + } + explorer.state.bind('change', function() { + $viewLink.val(makePermaLink(explorer.state)); + }); + $viewLink.val(makePermaLink(explorer.state)); + }, + // setup the loader menu in top bar setupLoader: function(callback) { // pre-populate webstore load form with an example url diff --git a/src/view.js b/src/view.js index 27df520e..159ed32e 100644 --- a/src/view.js +++ b/src/view.js @@ -682,6 +682,9 @@ my.composeQueryString = function(queryParams) { var queryString = '?'; var items = []; $.each(queryParams, function(key, value) { + if (typeof(value) === 'object') { + value = JSON.stringify(value); + } items.push(key + '=' + value); }); queryString += items.join('&'); From 3ef664fe7713152ddeda45acb2ebe53fa0ab5d1c Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 16 Apr 2012 20:47:47 +0100 Subject: [PATCH 04/20] [#88,#67,state,view,app][s]: restore was not in fact working correcty for views as we need to pass the state on initialization (we were setting the state later). * Also turn off url updating since I think that was messing with things and we were planning to do that. (For later: reintroduce this as it supports well-behaved navigation) --- src/view.js | 65 ++++++++++++++++++++++++----------------------- test/view.test.js | 3 ++- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/view.js b/src/view.js index 159ed32e..69ba9e34 100644 --- a/src/view.js +++ b/src/view.js @@ -185,6 +185,7 @@ my.DataExplorer = Backbone.View.extend({ var self = this; this.el = $(this.el); // Hash of 'page' views (i.e. those for whole page) keyed by page name + this._setupState(options.state); if (options.views) { this.pageViews = options.views; } else { @@ -192,26 +193,35 @@ my.DataExplorer = Backbone.View.extend({ id: 'grid', label: 'Grid', view: new my.Grid({ - model: this.model - }) + model: this.model, + state: this.state.get('view-grid') + }), }, { id: 'graph', label: 'Graph', view: new my.Graph({ - model: this.model - }) + model: this.model, + state: this.state.get('view-graph') + }), }, { id: 'map', label: 'Map', view: new my.Map({ - model: this.model - }) + model: this.model, + state: this.state.get('view-map') + }), }]; } // these must be called after pageViews are created this.render(); - // should come after render as may need to interact with elements in the view - this._setupState(options.state); + this._bindStateChanges(); + // now do updates based on state (need to come after render) + if (this.state.get('readOnly')) { + this.setReadOnly(); + } + if (this.state.get('currentView')) { + this.updateNav(this.state.get('currentView')); + } this.router = new Backbone.Router(); this.setupRouting(); @@ -227,7 +237,7 @@ my.DataExplorer = Backbone.View.extend({ var qs = my.parseHashQueryString(); qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON()); var out = my.getNewHashForQueryString(qs); - self.router.navigate(out); + // self.router.navigate(out); }); this.model.bind('query:fail', function(error) { my.clearNotifications(); @@ -290,13 +300,15 @@ my.DataExplorer = Backbone.View.extend({ setupRouting: function() { var self = this; // Default route - this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) { - self.updateNav(self.pageViews[0].id, queryString); - }); - $.each(this.pageViews, function(idx, view) { - self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { - self.updateNav(viewId, queryString); - }); +// this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) { +// self.updateNav(self.pageViews[0].id, queryString); +// }); +// $.each(this.pageViews, function(idx, view) { +// self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { +// self.updateNav(viewId, queryString); +// }); +// }); + this.router.route(/.*/, 'view', function() { }); }, @@ -356,29 +368,18 @@ my.DataExplorer = Backbone.View.extend({ 'view-graph': graphState, backend: this.model.backend.__type__, dataset: this.model.toJSON(), - currentView: this.pageViews[0].id, + currentView: null, readOnly: false }, initialState); this.state = new recline.Model.ObjectState(stateData); + }, - // now do updates based on state - if (this.state.get('readOnly')) { - this.setReadOnly(); - } - if (this.state.get('currentView')) { - this.updateNav(this.state.get('currentView')); - } - _.each(this.pageViews, function(pageView) { - var viewId = 'view-' + pageView.id; - if (viewId in self.state.attributes) { - pageView.view.state.set(self.state.get(viewId)); - } - }); - + _bindStateChanges: function() { + var self = this; // finally ensure we update our state object when state of sub-object changes so that state is always up to date this.model.queryState.bind('change', function() { - self.state.set({queryState: self.model.queryState.toJSON()}); + self.state.set({query: self.model.queryState.toJSON()}); }); _.each(this.pageViews, function(pageView) { if (pageView.view.state && pageView.view.state.bind) { diff --git a/test/view.test.js b/test/view.test.js index cb0d4a0c..19fcc177 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -26,9 +26,10 @@ test('get State', function () { var state = explorer.state; ok(state.get('query')); equal(state.get('readOnly'), false); - equal(state.get('currentView'), 'grid'); + equal(state.get('currentView'), null); equal(state.get('query').size, 100); deepEqual(state.get('view-grid').hiddenFields, []); + deepEqual(state.get('view-graph').group, null); equal(state.get('backend'), 'memory'); ok(state.get('dataset').id !== null); $el.remove(); From 9e08d6109b1b2892ac0da82bfec44b18df32cc7f Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 16 Apr 2012 21:05:40 +0100 Subject: [PATCH 05/20] [app][xs]: comment out router stuff which was obsoleted by last commit and is causing problems with online version of app. --- app/js/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/js/app.js b/app/js/app.js index ab6f4dc4..53e447b0 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -57,8 +57,8 @@ var ExplorerApp = Backbone.View.extend({ // HACK (a bit). Issue is that Backbone will not trigger the route // if you are already at that location so we have to make sure we genuinely switch if (reload) { - this.dataExplorer.router.navigate('graph'); - this.dataExplorer.router.navigate('', true); + // this.dataExplorer.router.navigate('graph'); + // this.dataExplorer.router.navigate('', true); } }, From 27b5cf243fda0ef5c6598674b2f1118a775fce47 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 17 Apr 2012 05:19:27 +0100 Subject: [PATCH 06/20] [bugfix,view][xs]: set default view in DataExplorer to first view if no currentView specified. --- src/view.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view.js b/src/view.js index 69ba9e34..358b2aa3 100644 --- a/src/view.js +++ b/src/view.js @@ -221,6 +221,8 @@ my.DataExplorer = Backbone.View.extend({ } if (this.state.get('currentView')) { this.updateNav(this.state.get('currentView')); + } else { + this.updateNav(this.pageViews[0].id); } this.router = new Backbone.Router(); From bdbfdd7c341c0eedbe313a84ed88cb9e4617f82d Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 17 Apr 2012 05:24:06 +0100 Subject: [PATCH 07/20] [#67,app][s]: embed support. --- app/index.html | 2 ++ app/js/app.js | 32 +++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/index.html b/app/index.html index cfd9492e..e6367736 100644 --- a/app/index.html +++ b/app/index.html @@ -162,6 +162,8 @@
    diff --git a/app/js/app.js b/app/js/app.js index 53e447b0..b5ef942d 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -24,6 +24,10 @@ var ExplorerApp = Backbone.View.extend({ } catch(e) {} state[key] = value; }); + if (state.embed) { + $('.navbar').hide(); + $('body').attr('style', 'padding-top: 0px'); + } } var dataset = null; if (state.dataset || state.url) { @@ -53,6 +57,7 @@ var ExplorerApp = Backbone.View.extend({ state: state }); this._setupPermaLink(this.dataExplorer); + this._setupEmbed(this.dataExplorer); // HACK (a bit). Issue is that Backbone will not trigger the route // if you are already at that location so we have to make sure we genuinely switch @@ -63,15 +68,32 @@ var ExplorerApp = Backbone.View.extend({ }, _setupPermaLink: function(explorer) { + var self = this; var $viewLink = this.el.find('.js-share-and-embed-dialog .view-link'); - function makePermaLink(state) { - var qs = recline.View.composeQueryString(state.toJSON()); - return window.location.origin + window.location.pathname + qs; + explorer.state.bind('change', function() { + $viewLink.val(self.makePermaLink(explorer.state)); + }); + $viewLink.val(self.makePermaLink(explorer.state)); + }, + + _setupEmbed: function(explorer) { + var self = this; + var $embedLink = this.el.find('.js-share-and-embed-dialog .view-embed'); + function makeEmbedLink(state) { + var link = self.makePermaLink(state); + link = link + '&embed=true'; + var out = $.mustache('', {link: link}); + return out; } explorer.state.bind('change', function() { - $viewLink.val(makePermaLink(explorer.state)); + $embedLink.val(makeEmbedLink(explorer.state)); }); - $viewLink.val(makePermaLink(explorer.state)); + $embedLink.val(makeEmbedLink(explorer.state)); + }, + + makePermaLink: function(state) { + var qs = recline.View.composeQueryString(state.toJSON()); + return window.location.origin + window.location.pathname + qs; }, // setup the loader menu in top bar From 5eb65f2b77deb997f2a4e8de9f709328736cc907 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 17 Apr 2012 05:27:09 +0100 Subject: [PATCH 08/20] [view-map,bugfix][xs]: only call invalidateMapsize on view:show if map defined. * this issue arose in case when map is the first view being shown. --- src/view-map.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/view-map.js b/src/view-map.js index 6759c55e..9ceb07b1 100644 --- a/src/view-map.js +++ b/src/view-map.js @@ -117,7 +117,9 @@ my.Map = Backbone.View.extend({ // If the div was hidden, Leaflet needs to recalculate some sizes // to display properly this.bind('view:show',function(){ - self.map.invalidateSize(); + if (self.map) { + self.map.invalidateSize(); + } }); var stateData = _.extend({ From 7973bf1dd450344d56fe30320afc24f5d606bb27 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 17 Apr 2012 11:17:06 +0100 Subject: [PATCH 09/20] [build][s]: regular build of docs and consolidated library. --- docs/backend/base.html | 153 +-- docs/backend/dataproxy.html | 72 +- docs/backend/elasticsearch.html | 95 +- docs/backend/gdocs.html | 205 +--- docs/backend/localcsv.html | 426 ++------- docs/backend/memory.html | 149 +-- docs/model.html | 484 +++------- .../{view-flot-graph.html => view-graph.html} | 363 ++----- docs/view-grid.html | 212 +---- docs/view-map.html | 473 ++-------- docs/view.html | 574 +++++------ recline.js | 889 ++++++++++++------ 12 files changed, 1445 insertions(+), 2650 deletions(-) rename docs/{view-flot-graph.html => view-graph.html} (63%) diff --git a/docs/backend/base.html b/docs/backend/base.html index 2be99510..9f022974 100644 --- a/docs/backend/base.html +++ b/docs/backend/base.html @@ -1,109 +1,67 @@ - - - - - base.js - - - -
    -
    -
    -

    base.js

    -
    -
    -
    -
    -
    - # -
    -

    Recline Backends

    + base.js

    base.js

    Recline Backends

    +

    Backends are connectors to backend data sources and stores

    -

    This is just the base module containing a template Base class and convenience methods.

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

    This is just the base module containing a template Base class and convenience methods.

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

    Backbone.sync

    -

    Override Backbone.sync to hand off to sync function in relevant backend

    -
    -
    -
      Backbone.sync = function(method, model, options) {
    +(function($, my) {

    Backbone.sync

    + +

    Override Backbone.sync to hand off to sync function in relevant backend

      Backbone.sync = function(method, model, options) {
         return model.backend.sync(method, model, options);
    -  };
    - - -
    -
    -
    -
    - # -
    -

    recline.Backend.Base

    + };

    recline.Backend.Base

    +

    Base class for backends providing a template and convenience functions. -You do not have to inherit from this class but even when not it does provide guidance on the functions you must implement.

    -

    Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.

    - -
    -
      my.Base = Backbone.Model.extend({
    -
    - -
    -
    -
    -
    - # -
    -

    sync

    +You do not have to inherit from this class but even when not it does +provide guidance on the functions you must implement.

    + +

    Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.

      my.Base = Backbone.Model.extend({

    type

    + +

    'type' of this backend. This should be either the class path for this +object as a string (e.g. recline.Backend.Memory) or for Backends within +recline.Backend module it may be their class name.

    + +

    This value is used as an identifier for this backend when initializing +backends (see recline.Model.Dataset.initialize).

        __type__: 'base',

    sync

    +

    An implementation of Backbone.sync that will be used to override Backbone.sync on operations for Datasets and Documents which are using this backend.

    +

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

    +

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

    -

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

    - -
    -
        sync: function(method, model, options) {
    -    },
    -
    - -
    -
    -
    -
    - # -
    -

    query

    + +

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

        sync: function(method, model, options) {
    +    },
    +    

    query

    +

    Query the backend for documents returning them in bulk. This method will be used by the Dataset.query method to search the backend for documents, retrieving the results in bulk.

    +

    @param {recline.model.Dataset} model: Dataset model.

    +

    @param {Object} queryObj: object describing a query (usually produced by using recline.Model.Query and calling toJSON on it).

    +

    The structure of data in the Query object or Hash should follow that defined in issue 34. (Of course, if you are writing your own backend, and hence have control over the interpretation of the query object, you can use whatever structure you like).

    +

    @returns {Promise} promise API object. The promise resolve method will be called on query completion with a QueryResult object.

    +

    A QueryResult has the following structure (modelled closely on ElasticSearch - see this issue for more details):

    +
     {
       total: // (required) total number of results (can be null)
    @@ -118,23 +76,8 @@ details):

    // facet results (as per ) } } -
    - -
    -
        query: function(model, queryObj) {
    -    },
    -
    - -
    -
    -
    -
    - # -
    -

    convenience method to convert simple set of documents / rows to a QueryResult

    -
    -
    -
        _docsToQueryResult: function(rows) {
    +
        query: function(model, queryObj) {
    +    },

    convenience method to convert simple set of documents / rows to a QueryResult

        _docsToQueryResult: function(rows) {
           var hits = _.map(rows, function(row) {
             return { _source: row };
           });
    @@ -142,22 +85,11 @@ details):

    total: null, hits: hits }; - },
    - - -
    -
    -
    -
    - # -
    -

    _wrapInTimeout

    + },

    _wrapInTimeout

    +

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

    - -
    -
        _wrapInTimeout: function(ourFunction) {
    +a crude way to catch those errors.

        _wrapInTimeout: function(ourFunction) {
           var dfd = $.Deferred();
           var timeout = 5000;
           var timer = setTimeout(function() {
    @@ -180,9 +112,4 @@ a crude way to catch those errors.

    }(jQuery, this.recline.Backend)); -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/backend/dataproxy.html b/docs/backend/dataproxy.html index 30e64829..b65ef570 100644 --- a/docs/backend/dataproxy.html +++ b/docs/backend/dataproxy.html @@ -1,72 +1,33 @@ - - - - - dataproxy.js - - - -
    -
    -
    -

    dataproxy.js

    -
    -
    -
    -
    -
    - # -
    - -
    -
    -
    this.recline = this.recline || {};
    +      dataproxy.js           

    dataproxy.js

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

    DataProxy Backend

    +(function($, my) {

    DataProxy Backend

    +

    For connecting to DataProxy-s.

    +

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

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

    Datasets using using this backend should set the following attributes:

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

    Note that this is a read-only backend.

    - -
    -
      my.DataProxy = my.Base.extend({
    +
    +

    Note that this is a read-only backend.

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

    Do nothing as we will get fields in query step (and no metadata to -retrieve)

    -
    -
    -
              var dfd = $.Deferred();
    +        if (model.__type__ == 'Dataset') {

    Do nothing as we will get fields in query step (and no metadata to +retrieve)

              var dfd = $.Deferred();
               dfd.resolve(model);
               return dfd.promise();
             }
    @@ -111,14 +72,7 @@ retrieve)

    return dfd.promise(); } }); - recline.Model.backends['dataproxy'] = new my.DataProxy(); - }(jQuery, this.recline.Backend)); -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/backend/elasticsearch.html b/docs/backend/elasticsearch.html index 6c296914..0257f24c 100644 --- a/docs/backend/elasticsearch.html +++ b/docs/backend/elasticsearch.html @@ -1,41 +1,13 @@ - - - - - elasticsearch.js - - - -
    -
    -
    -

    elasticsearch.js

    -
    -
    -
    -
    -
    - # -
    - -
    -
    -
    this.recline = this.recline || {};
    +      elasticsearch.js           

    elasticsearch.js

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

    ElasticSearch Backend

    +(function($, my) {

    ElasticSearch Backend

    +

    Connecting to ElasticSearch.

    +

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

    +
     elasticsearch_url
     webstore_url
    @@ -44,10 +16,9 @@ url
     
     

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

    -
    http://localhost:9200/twitter/tweet
    - -
    -
      my.ElasticSearch = my.Base.extend({
    +
    +
    http://localhost:9200/twitter/tweet
      my.ElasticSearch = my.Base.extend({
    +    __type__: 'elasticsearch',
         _getESUrl: function(dataset) {
           var out = dataset.get('elasticsearch_url');
           if (out) return out;
    @@ -67,19 +38,7 @@ localhost:9200 with index twitter and type tweet it would be

    dataType: 'jsonp' }); var dfd = $.Deferred(); - this._wrapInTimeout(jqxhr).done(function(schema) {
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
                var key = _.keys(schema)[0];
    +          this._wrapInTimeout(jqxhr).done(function(schema) {

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

                var key = _.keys(schema)[0];
                 var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
                   dict.id = fieldName;
                   return dict;
    @@ -112,19 +71,7 @@ localhost:9200 with index twitter and type tweet it would be

    } }; delete out.q; - }
    - - -
    -
    -
    -
    - # -
    -

    now do filters (note the plural)

    -
    -
    -
          if (out.filters && out.filters.length) {
    +      }

    now do filters (note the plural)

          if (out.filters && out.filters.length) {
             if (!out.filter) {
               out.filter = {};
             }
    @@ -147,19 +94,7 @@ localhost:9200 with index twitter and type tweet it would be

    data: data, dataType: 'jsonp' }); - var dfd = $.Deferred();
    - - -
    -
    -
    -
    - # -
    -

    TODO: fail case

    -
    -
    -
          jqxhr.done(function(results) {
    +      var dfd = $.Deferred();

    TODO: fail case

          jqxhr.done(function(results) {
             _.each(results.hits.hits, function(hit) {
               if (!('id' in hit._source) && hit._id) {
                 hit._source.id = hit._id;
    @@ -173,13 +108,7 @@ localhost:9200 with index twitter and type tweet it would be

    return dfd.promise(); } }); - recline.Model.backends['elasticsearch'] = new my.ElasticSearch(); }(jQuery, this.recline.Backend)); -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/backend/gdocs.html b/docs/backend/gdocs.html index 60fba435..4d23ae12 100644 --- a/docs/backend/gdocs.html +++ b/docs/backend/gdocs.html @@ -1,68 +1,26 @@ - - - - - gdocs.js - - - -
    -
    -
    -

    gdocs.js

    -
    -
    -
    -
    -
    - # -
    - -
    -
    -
    this.recline = this.recline || {};
    +      gdocs.js           

    gdocs.js

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

    Google spreadsheet backend

    +(function($, my) {

    Google spreadsheet backend

    +

    Connect to Google Docs spreadsheet.

    +

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

    +
     var dataset = new recline.Model.Dataset({
         url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
       },
       'gdocs'
     );
    -
    - -
    -
      my.GDoc = my.Base.extend({
    +
      my.GDoc = my.Base.extend({
    +    __type__: 'gdoc',
         getUrl: function(dataset) {
           var url = dataset.get('url');
           if (url.indexOf('feeds/list') != -1) {
             return url;
    -      } else {
    - - -
    -
    -
    -
    - # -
    -

    https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0

    -
    -
    -
            var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
    +      } else {

    https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0

            var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
             var matches = url.match(regex);
             if (matches) {
               var key = matches[1];
    @@ -87,19 +45,7 @@ var dataset = new recline.Model.Dataset({
               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;
    +          );

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

              model._dataCache = result.data;
               dfd.resolve(model);
             });
             return dfd.promise();
    @@ -108,20 +54,8 @@ var dataset = new recline.Model.Dataset({
     
         query: function(dataset, queryObj) { 
           var dfd = $.Deferred();
    -      var fields = _.pluck(dataset.fields.toJSON(), 'id');
    - - -
    -
    -
    -
    - # -
    -

    zip the fields with the data rows to produce js objs -TODO: factor this out as a common method with other backends

    -
    -
    -
          var objs = _.map(dataset._dataCache, function (d) { 
    +      var 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];
    @@ -131,82 +65,27 @@ TODO: factor this out as a common method with other backends

    dfd.resolve(this._docsToQueryResult(objs)); return dfd; }, - gdocsToJavascript: function(gdocsSpreadsheet) {
    - - -
    -
    -
    -
    - # -
    -

    :options: (optional) optional argument dictionary: - columnsToUse: list of columns to use (specified by field names) - colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). - :return: tabular data object (hash with keys: field and data).

    -

    Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.

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

    default is no special info on type of columns

    -
    -
    -
          var colTypes = {};
    +      };

    default is no special info on type of columns

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

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

    -
    -
    -
          if (options.columnsToUse) {
    -
    -
    -
    -
    -
    -
    - # -
    -

    columns set to subset supplied

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

    set columns to use to be all available

    -
    -
    -
            if (gdocsSpreadsheet.feed.entry.length > 0) {
    +      }

    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);
    @@ -214,37 +93,13 @@ TODO: factor this out as a common method with other backends

    } } } - }
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
          var rep = /^([\d\.\-]+)\%$/;
    +      }

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

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

    if labelled as % and value contains %, convert

    -
    -
    -
              if (colTypes[col] == 'percent') {
    +          var value = entry[_keyname]['$t'];

    if labelled as % and value contains %, convert

              if (colTypes[col] == 'percent') {
                 if (rep.test(value)) {
                   var value2 = rep.exec(value);
                   var value3 = parseFloat(value2);
    @@ -258,13 +113,7 @@ TODO: factor this out as a common method with other backends

    return results; } }); - recline.Model.backends['gdocs'] = new my.GDoc(); }(jQuery, this.recline.Backend)); -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/backend/localcsv.html b/docs/backend/localcsv.html index 69dfb80b..71f105bf 100644 --- a/docs/backend/localcsv.html +++ b/docs/backend/localcsv.html @@ -1,58 +1,26 @@ - - - - - localcsv.js - - - -
    -
    -
    -

    localcsv.js

    -
    -
    -
    -
    -
    - # -
    - -
    -
    -
    this.recline = this.recline || {};
    +      localcsv.js           

    localcsv.js

    this.recline = this.recline || {};
     this.recline.Backend = this.recline.Backend || {};
     
     (function($, my) {
    -  my.loadFromCSVFile = function(file, callback) {
    +  my.loadFromCSVFile = function(file, callback, options) {
    +    var encoding = options.encoding || 'UTF-8';
    +    
         var metadata = {
           id: file.name,
           file: file
         };
    -    var reader = new FileReader();
    - - -
    -
    -
    -
    - # -
    -

    TODO

    -
    -
    -
        reader.onload = function(e) {
    -      var dataset = my.csvToDataset(e.target.result);
    +    var reader = new FileReader();

    TODO

        reader.onload = function(e) {
    +      var dataset = my.csvToDataset(e.target.result, options);
           callback(dataset);
         };
         reader.onerror = function (e) {
           alert('Failed to load file. Code: ' + e.target.error.code);
         };
    -    reader.readAsText(file);
    +    reader.readAsText(file, encoding);
       };
     
    -  my.csvToDataset = function(csvString) {
    -    var out = my.parseCSV(csvString);
    +  my.csvToDataset = function(csvString, options) {
    +    var out = my.parseCSV(csvString, options);
         fields = _.map(out[0], function(cell) {
           return { id: cell, label: cell };
         });
    @@ -65,319 +33,95 @@
         });
         var dataset = recline.Backend.createDataset(data, fields);
         return dataset;
    -  };
    - - -
    -
    -
    -
    - # -
    -

    Converts a Comma Separated Values string into an array of arrays. + };

    Converts a Comma Separated Values string into an array of arrays. Each line in the CSV becomes an array.

    +

    Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.

    +

    @return The CSV parsed as an array @type Array

    +

    @param {String} s The string to convert -@param {Boolean} [trm=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported

    -

    Heavily based on uselesscode's JS CSV parser (MIT Licensed): -thttp://www.uselesscode.org/javascript/csv/

    - -
    -
    	my.parseCSV= function(s, trm) {
    -
    - -
    -
    -
    -
    - # -
    -

    Get rid of any trailing \n

    -
    -
    -
    		s = chomp(s);
    +@param {Object} options Options for loading CSV including
    +    @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
    +@param {String} [separator=','] Separator for CSV file
    +Heavily based on uselesscode's JS CSV parser (MIT Licensed):
    +thttp://www.uselesscode.org/javascript/csv/

      my.parseCSV= function(s, options) {

    Get rid of any trailing \n

        s = chomp(s);
     
    -		var cur = '', // The character we are currently processing.
    -			inQuote = false,
    -			fieldQuoted = false,
    -			field = '', // Buffer for building up the current field
    -			row = [],
    -			out = [],
    -			i,
    -			processField;
    +    var options = options || {};
    +    var trm = options.trim;
    +    var separator = options.separator || ',';
    +    
    +    var cur = '', // The character we are currently processing.
    +      inQuote = false,
    +      fieldQuoted = false,
    +      field = '', // Buffer for building up the current field
    +      row = [],
    +      out = [],
    +      i,
    +      processField;
     
    -		processField = function (field) {
    -			if (fieldQuoted !== true) {
    - - -
    -
    -
    -
    - # -
    -

    If field is empty set to null

    -
    -
    -
    				if (field === '') {
    -					field = null;
    -
    -
    -
    -
    -
    -
    - # -
    -

    If the field was not quoted and we are trimming fields, trim it

    -
    -
    -
    				} else if (trm === true) {
    -					field = trim(field);
    -				}
    -
    -
    -
    -
    -
    -
    - # -
    -

    Convert unquoted numbers to their appropriate types

    -
    -
    -
    				if (rxIsInt.test(field)) {
    -					field = parseInt(field, 10);
    -				} else if (rxIsFloat.test(field)) {
    -					field = parseFloat(field, 10);
    -				}
    -			}
    -			return field;
    -		};
    +    processField = function (field) {
    +      if (fieldQuoted !== true) {

    If field is empty set to null

            if (field === '') {
    +          field = null;

    If the field was not quoted and we are trimming fields, trim it

            } else if (trm === true) {
    +          field = trim(field);
    +        }

    Convert unquoted numbers to their appropriate types

            if (rxIsInt.test(field)) {
    +          field = parseInt(field, 10);
    +        } else if (rxIsFloat.test(field)) {
    +          field = parseFloat(field, 10);
    +        }
    +      }
    +      return field;
    +    };
     
    -		for (i = 0; i < s.length; i += 1) {
    -			cur = s.charAt(i);
    - - -
    -
    -
    -
    - # -
    -

    If we are at a EOF or EOR

    -
    -
    -
    			if (inQuote === false && (cur === ',' || cur === "\n")) {
    -				field = processField(field);
    -
    -
    -
    -
    -
    -
    - # -
    -

    Add the current field to the current row

    -
    -
    -
    				row.push(field);
    -
    -
    -
    -
    -
    -
    - # -
    -

    If this is EOR append row to output and flush row

    -
    -
    -
    				if (cur === "\n") {
    -					out.push(row);
    -					row = [];
    -				}
    -
    -
    -
    -
    -
    -
    - # -
    -

    Flush the field buffer

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

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

    -
    -
    -
    				if (cur !== '"') {
    -					field += cur;
    -				} else {
    -					if (!inQuote) {
    -
    -
    -
    -
    -
    -
    - # -
    -

    We are not in a quote, start a quote

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

    Next char is ", this is an escaped "

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

    Skip the next char

    -
    -
    -
    							i += 1;
    -						} else {
    -
    -
    -
    -
    -
    -
    - # -
    -

    It's not escaping, so end quote

    -
    -
    -
    							inQuote = false;
    -						}
    -					}
    -				}
    -			}
    -		}
    -
    -
    -
    -
    -
    -
    - # -
    -

    Add the last field

    -
    -
    -
    		field = processField(field);
    -		row.push(field);
    -		out.push(row);
    +    for (i = 0; i < s.length; i += 1) {
    +      cur = s.charAt(i);

    If we are at a EOF or EOR

          if (inQuote === false && (cur === separator || cur === "\n")) {
    +	field = processField(field);

    Add the current field to the current row

            row.push(field);

    If this is EOR append row to output and flush row

            if (cur === "\n") {
    +          out.push(row);
    +          row = [];
    +        }

    Flush the field buffer

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

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

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

    We are not in a quote, start a quote

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

    Next char is ", this is an escaped "

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

    Skip the next char

                  i += 1;
    +            } else {

    It's not escaping, so end quote

                  inQuote = false;
    +            }
    +          }
    +        }
    +      }
    +    }

    Add the last field

        field = processField(field);
    +    row.push(field);
    +    out.push(row);
     
    -		return out;
    -	};
    +    return out;
    +  };
     
    -	var rxIsInt = /^\d+$/,
    -		rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
    - - -
    -
    -
    -
    - # -
    -

    If a string has leading or trailing space, + var rxIsInt = /^\d+$/, + rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,

    If a string has leading or trailing space, contains a comma double quote or a newline -it needs to be quoted in CSV output

    - -
    -
    		rxNeedsQuoting = /^\s|\s$|,|"|\n/,
    -		trim = (function () {
    -
    - -
    -
    -
    -
    - # -
    -

    Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists

    -
    -
    -
    			if (String.prototype.trim) {
    -				return function (s) {
    -					return s.trim();
    -				};
    -			} else {
    -				return function (s) {
    -					return s.replace(/^\s*/, '').replace(/\s*$/, '');
    -				};
    -			}
    -		}());
    +it needs to be quoted in CSV output

        rxNeedsQuoting = /^\s|\s$|,|"|\n/,
    +    trim = (function () {

    Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists

          if (String.prototype.trim) {
    +        return function (s) {
    +          return s.trim();
    +        };
    +      } else {
    +        return function (s) {
    +          return s.replace(/^\s*/, '').replace(/\s*$/, '');
    +        };
    +      }
    +    }());
     
    -	function chomp(s) {
    -		if (s.charAt(s.length - 1) !== "\n") {
    - - -
    -
    -
    -
    - # -
    -

    Does not end with \n, just return string

    -
    -
    -
    			return s;
    -		} else {
    -
    -
    -
    -
    -
    -
    - # -
    -

    Remove the \n

    -
    -
    -
    			return s.substring(0, s.length - 1);
    -		}
    -	}
    +  function chomp(s) {
    +    if (s.charAt(s.length - 1) !== "\n") {

    Does not end with \n, just return string

          return s;
    +    } else {

    Remove the \n

          return s.substring(0, s.length - 1);
    +    }
    +  }
     
     
     }(jQuery, this.recline.Backend));
     
    -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/backend/memory.html b/docs/backend/memory.html index 5403926c..b55f36f9 100644 --- a/docs/backend/memory.html +++ b/docs/backend/memory.html @@ -1,56 +1,24 @@ - - - - - memory.js - - - -
    -
    -
    -

    memory.js

    -
    -
    -
    -
    -
    - # -
    - -
    -
    -
    this.recline = this.recline || {};
    +      memory.js           

    memory.js

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

    createDataset

    +(function($, my) {

    createDataset

    +

    Convenience function to create a simple 'in-memory' dataset in one step.

    +

    @param data: list of hashes for each document/row in the data ({key: value, key: value}) @param fields: (optional) list of field hashes (each hash defining a hash as per recline.Model.Field). If fields not specified they will be taken from the data. @param metadata: (optional) dataset metadata - see recline.Model.Dataset. -If not defined (or id not provided) id will be autogenerated.

    - -
    -
      my.createDataset = function(data, fields, metadata) {
    +If not defined (or id not provided) id will be autogenerated.

      my.createDataset = function(data, fields, metadata) {
         if (!metadata) {
           metadata = {};
         }
         if (!metadata.id) {
           metadata.id = String(Math.floor(Math.random() * 100000000) + 1);
         }
    -    var backend = recline.Model.backends['memory'];
    +    var backend = new recline.Backend.Memory();
         var datasetInfo = {
           documents: data,
           metadata: metadata
    @@ -65,25 +33,20 @@ If not defined (or id not provided) id will be autogenerated.

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

    Memory Backend - uses in-memory data

    + };

    Memory Backend - uses in-memory data

    +

    To use it you should provide in your constructor data:

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

    Example:

    +

      // Backend setup
      var backend = recline.Backend.Memory();
    @@ -102,10 +65,8 @@ If not defined (or id not provided) id will be autogenerated.

    var dataset = Dataset({id: 'my-id'}, 'memory'); dataset.fetch(); etc ... -

    - -
    -
      my.Memory = my.Base.extend({
    + 

      my.Memory = my.Base.extend({
    +    __type__: 'memory',
         initialize: function() {
           this.datasets = {};
         },
    @@ -153,25 +114,9 @@ If not defined (or id not provided) id will be autogenerated.

    var out = {}; var numRows = queryObj.size; var start = queryObj.from; - results = this.datasets[model.id].documents; - _.each(queryObj.filters, function(filter) { - results = _.filter(results, function(doc) { - var fieldId = _.keys(filter.term)[0]; - return (doc[fieldId] == filter.term[fieldId]); - }); - });
    - - -
    -
    -
    -
    - # -
    -

    not complete sorting!

    -
    -
    -
          _.each(queryObj.sort, function(sortObj) {
    +      var results = this.datasets[model.id].documents;
    +      results = this._applyFilters(results, queryObj);
    +      results = this._applyFreeTextQuery(model, results, queryObj);

    not complete sorting!

          _.each(queryObj.sort, function(sortObj) {
             var fieldName = _.keys(sortObj)[0];
             results = _.sortBy(results, function(doc) {
               var _out = doc[fieldName];
    @@ -185,6 +130,30 @@ If not defined (or id not provided) id will be autogenerated.

    out.total = total; dfd.resolve(out); return dfd.promise(); + },

    in place filtering

        _applyFilters: function(results, queryObj) {
    +      _.each(queryObj.filters, function(filter) {
    +        results = _.filter(results, function(doc) {
    +          var fieldId = _.keys(filter.term)[0];
    +          return (doc[fieldId] == filter.term[fieldId]);
    +        });
    +      });
    +      return results;
    +    },

    we OR across fields but AND across terms in query string

        _applyFreeTextQuery: function(dataset, results, queryObj) {
    +      if (queryObj.q) {
    +        var terms = queryObj.q.split(' ');
    +        results = _.filter(results, function(rawdoc) {
    +          var matches = true;
    +          _.each(terms, function(term) {
    +            var foundmatch = false;
    +            dataset.fields.each(function(field) {
    +              var value = rawdoc[field.id].toString();

    TODO regexes?

                  foundmatch = foundmatch || (value === term);

    TODO: early out (once we are true should break to spare unnecessary testing) +if (foundmatch) return true;

                });
    +            matches = matches && foundmatch;

    TODO: early out (once false should break to spare unnecessary testing) +if (!matches) return false;

              });
    +          return matches;
    +        });
    +      }
    +      return results;
         },
     
         _computeFacets: function(documents, queryObj) {
    @@ -195,19 +164,7 @@ If not defined (or id not provided) id will be autogenerated.

    _.each(queryObj.facets, function(query, facetId) { facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); facetResults[facetId].termsall = {}; - });
    - - -
    -
    -
    -
    - # -
    -

    faceting

    -
    -
    -
          _.each(documents, function(doc) {
    +      });

    faceting

          _.each(documents, function(doc) {
             _.each(queryObj.facets, function(query, facetId) {
               var fieldId = query.terms.field;
               var val = doc[fieldId];
    @@ -224,32 +181,14 @@ If not defined (or id not provided) id will be autogenerated.

    var terms = _.map(tmp.termsall, function(count, term) { return { term: term, count: count }; }); - tmp.terms = _.sortBy(terms, function(item) {
    - - -
    -
    -
    -
    - # -
    -

    want descending order

    -
    -
    -
              return -item.count;
    +        tmp.terms = _.sortBy(terms, function(item) {

    want descending order

              return -item.count;
             });
             tmp.terms = tmp.terms.slice(0, 10);
           });
           return facetResults;
         }
       });
    -  recline.Model.backends['memory'] = new my.Memory();
     
     }(jQuery, this.recline.Backend));
     
    -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/model.html b/docs/model.html index 92dbe7fe..c43556ca 100644 --- a/docs/model.html +++ b/docs/model.html @@ -1,73 +1,44 @@ - - - - - model.js - - - -
    -
    -
    -

    model.js

    -
    -
    -
    -
    -
    - # -
    -

    Recline Backbone Models

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

    model.js

    Recline Backbone Models

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

    A Dataset model

    +(function($, my) {

    A Dataset model

    +

    A model has the following (non-Backbone) attributes:

    +

    @property {FieldList} fields: (aka columns) is a FieldList listing all the fields on this Dataset (this can be set explicitly, or, will be set by Dataset.fetch() or Dataset.query()

    +

    @property {DocumentList} currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (updated by calling query method)

    +

    @property {number} docCount: total number of documents in this dataset

    -

    @property {Backend} backend: the Backend (instance) for this Dataset

    + +

    @property {Backend} backend: the Backend (instance) for this Dataset.

    +

    @property {Query} queryState: Query object which stores current queryState. queryState may be edited by other components (e.g. a query editor view) changes will trigger a Dataset query.

    +

    @property {FacetList} facets: FacetList object containing all current -Facets.

    - -
    -
    my.Dataset = Backbone.Model.extend({
    -  __type__: 'Dataset',
    -
    - -
    -
    -
    -
    - # -
    -

    initialize

    +Facets.

    my.Dataset = Backbone.Model.extend({
    +  __type__: 'Dataset',

    initialize

    +

    Sets up instance properties (see above)

    - -
    -
      initialize: function(model, backend) {
    +
    +

    @param {Object} model: standard set of model attributes passed to Backbone models

    + +

    @param {Object or String} backend: Backend instance (see +recline.Backend.Base) or a string specifying that instance. The +string specifying may be a full class path e.g. +'recline.Backend.ElasticSearch' or a simple name e.g. +'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in +recline.Backend module)

      initialize: function(model, backend) {
         _.bindAll(this, 'query');
         this.backend = backend;
    -    if (backend && backend.constructor == String) {
    -      this.backend = my.backends[backend];
    +    if (typeof(backend) === 'string') {
    +      this.backend = this._backendFromString(backend);
         }
         this.fields = new my.FieldList();
         this.currentDocuments = new my.DocumentList();
    @@ -76,24 +47,15 @@ Facets.

    this.queryState = new my.Query(); this.queryState.bind('change', this.query); this.queryState.bind('facet:add', this.query); - },
    - - -
    -
    -
    -
    - # -
    -

    query

    + },

    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) {
    +also returned.

      query: function(queryObj) {
         var self = this;
         this.trigger('query:start');
         var actualQuery = self._prepareQuery(queryObj);
    @@ -137,39 +99,67 @@ also returned.

    data.docCount = this.docCount; data.fields = this.fields.toJSON(); return data; + },

    _backendFromString(backendString)

    + +

    See backend argument to initialize for details

      _backendFromString: function(backendString) {
    +    var parts = backendString.split('.');

    walk through the specified path xxx.yyy.zzz to get the final object which should be backend class

        var current = window;
    +    for(ii=0;ii<parts.length;ii++) {
    +      if (!current) {
    +        break;
    +      }
    +      current = current[parts[ii]];
    +    }
    +    if (current) {
    +      return new current();
    +    }

    alternatively we just had a simple string

        var backend = null;
    +    if (recline && recline.Backend) {
    +      _.each(_.keys(recline.Backend), function(name) {
    +        if (name.toLowerCase() === backendString.toLowerCase()) {
    +          backend = new recline.Backend[name]();
    +        }
    +      });
    +    }
    +    return backend;
       }
    -});
    - - -
    -
    -
    -
    - # -
    -

    A Document (aka Row)

    -

    A single entry or row in the dataset

    -
    -
    -
    my.Document = Backbone.Model.extend({
    +});

    Dataset.restore

    + +

    Restore a Dataset instance from a serialized state. Serialized state for a +Dataset is an Object like:

    + +

    +{
    +  backend: {backend type - i.e. value of dataset.backend.type}
    +  dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler }
    +  // convenience - if url provided and dataste not this be used as dataset url
    +  url: {dataset url}
    +  ...
    +}

    my.Dataset.restore = function(state) {

    hack-y - restoring a memory dataset does not mean much ...

      var dataset = null;
    +  if (state.url && !state.dataset) {
    +    state.dataset = {url: state.url};
    +  }
    +  if (state.backend === 'memory') {
    +    dataset = recline.Backend.createDataset(
    +      [{stub: 'this is a stub dataset because we do not restore memory datasets'}],
    +      [],
    +      state.dataset // metadata
    +    );
    +  } else {
    +    dataset = new recline.Model.Dataset(
    +      state.dataset,
    +      state.backend
    +    );
    +  }
    +  return dataset;
    +};

    A Document (aka Row)

    + +

    A single entry or row in the dataset

    my.Document = Backbone.Model.extend({
       __type__: 'Document',
       initialize: function() {
         _.bindAll(this, 'getFieldValue');
    -  },
    - - -
    -
    -
    -
    - # -
    -

    getFieldValue

    + },

    getFieldValue

    +

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

    - -
    -
      getFieldValue: function(field) {
    +for this document.

      getFieldValue: function(field) {
         var val = this.get(field.id);
         if (field.deriver) {
           val = field.deriver(val, field, this);
    @@ -179,101 +169,48 @@ for this document.

    } return val; } -});
    - - -
    -
    -
    -
    - # -
    -

    A Backbone collection of Documents

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

    A Backbone collection of Documents

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

    A Field (aka Column) on a Dataset

    +

    Following (Backbone) attributes as standard:

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

    Following additional instance properties:

    +

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

    +

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

    - -
    -
    my.Field = Backbone.Model.extend({
    -
    - -
    -
    -
    -
    - # -
    -

    defaults - define default values

    -
    -
    -
      defaults: {
    +value of this field prior to rendering.

    my.Field = Backbone.Model.extend({

    defaults - define default values

      defaults: {
         label: null,
         type: 'string',
         format: null,
         is_derived: false
    -  },
    - - -
    -
    -
    -
    - # -
    -

    initialize

    + },

    initialize

    +

    @param {Object} data: standard Backbone model attributes

    -

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

    - -
    -
      initialize: function(data, options) {
    -
    - -
    -
    -
    -
    - # -
    -

    if a hash not passed in the first argument throw error

    -
    -
    -
        if ('0' in data) {
    +
    +

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

      initialize: function(data, options) {

    if a hash not passed in the first argument throw error

        if ('0' in data) {
           throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
         }
         if (this.attributes.label === null) {
    @@ -302,27 +239,24 @@ value of this field prior to rendering.

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

    Query

    +});

    Query

    +

    Query instances encapsulate a query to the backend (see query method on backend). Useful both for creating queries and for storing and manipulating query state - e.g. from a query editor).

    +

    Query Structure and format

    +

    Query structure should follow that of ElasticSearch query language.

    +

    NB: It is up to specific backends how to implement and support this query structure. Different backends might choose to implement things differently or not support certain features. Please check your backend for details.

    +

    Query object has the following key attributes:

    +
    • size (=limit): number of results to return
    • from (=offset): offset into result set - http://www.elasticsearch.org/guide/reference/api/search/from-size.html
    • @@ -332,24 +266,23 @@ or not support certain features. Please check your backend for details.
    • fields: set of fields to return - http://www.elasticsearch.org/guide/reference/api/search/fields.html
    • facets: specification of facets - see http://www.elasticsearch.org/guide/reference/api/search/facets/
    +

    Additions:

    +
      -
    • -

      q: either straight text or a hash will map directly onto a query_string - query - in backend

      -
    • -
    • -

      Of course this can be re-interpreted by different backends. E.g. some - may just pass this straight through e.g. for an SQL backend this could be - the full SQL query

      -
    • -
    • -

      filters: dict of ElasticSearch filters. These will be and-ed together for - execution.

      -
    • +
    • q: either straight text or a hash will map directly onto a query_string +query +in backend

      + +
      • Of course this can be re-interpreted by different backends. E.g. some +may just pass this straight through e.g. for an SQL backend this could be +the full SQL query
    • +
    • filters: dict of ElasticSearch filters. These will be and-ed together for +execution.

    +

    Examples

    +
     {
        q: 'quick brown fox',
    @@ -357,122 +290,40 @@ or not support certain features. Please check your backend for details.
          { term: { 'owner': 'jones' } }
        ]
     }
    -
    - -
    -
    my.Query = Backbone.Model.extend({
    +
    my.Query = Backbone.Model.extend({
       defaults: function() {
         return {
           size: 100,
           from: 0,
    -      facets: {},
    - - -
    -
    - -
    -
          filters: []
    +      facets: {},

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

          filters: []
         };
    -  },
    - - -
    -
    -
    -
    - # -
    -

    addTermFilter

    + },

    addTermFilter

    +

    Set (update or add) a terms filter to filters

    -

    See http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html

    - -
    -
      addTermFilter: function(fieldId, value) {
    +
    +

    See http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html

      addTermFilter: function(fieldId, value) {
         var filters = this.get('filters');
         var filter = { term: {} };
         filter.term[fieldId] = value;
         filters.push(filter);
    -    this.set({filters: filters});
    - - -
    -
    -
    -
    - # -
    -

    change does not seem to be triggered automatically

    -
    -
    -
        if (value) {
    +    this.set({filters: filters});

    change does not seem to be triggered automatically

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

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

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

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

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

    removeFilter

    -

    Remove a filter from filters at index filterIndex

    -
    -
    -
      removeFilter: function(filterIndex) {
    +  },

    removeFilter

    + +

    Remove a filter from filters at index filterIndex

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

    addFacet

    + },

    addFacet

    +

    Add a Facet to this query

    -

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

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

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

    -
    -
    -
        if (_.contains(_.keys(facets), fieldId)) {
    +
    +

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

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

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

        if (_.contains(_.keys(facets), fieldId)) {
           return;
         }
         facets[fieldId] = {
    @@ -492,24 +343,19 @@ or not support certain features. Please check your backend for details.
         this.set({facets: facets}, {silent: true});
         this.trigger('facet:add', this);
       }
    -});
    - - -
    -
    -
    -
    - # -
    -

    A Facet (Result)

    +});

    A Facet (Result)

    +

    Object to store Facet information, that is summary information (e.g. values and counts) about a field obtained by some faceting method on the backend.

    +

    Structure of a facet follows that of Facet results in ElasticSearch, see: http://www.elasticsearch.org/guide/reference/api/search/facets/

    +

    Specifically the object structure of a facet looks like (there is one addition compared to ElasticSearch: the "id" field which corresponds to the key used to specify this facet in the facet query):

    +
     {
       "id": "id-of-facet",
    @@ -534,10 +380,7 @@ key used to specify this facet in the facet query):

    } ] } -
    - -
    -
    my.Facet = Backbone.Model.extend({
    +
    my.Facet = Backbone.Model.extend({
       defaults: function() {
         return {
           _type: 'terms',
    @@ -547,40 +390,15 @@ key used to specify this facet in the facet query):

    terms: [] }; } -});
    - - -
    -
    -
    -
    - # -
    -

    A Collection/List of Facets

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

    A Collection/List of Facets

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

    Backend registry

    -

    Backends will register themselves by id into this registry

    -
    -
    -
    my.backends = {};
    +});

    Object State

    + +

    Convenience Backbone model for storing (configuration) state of objects like Views.

    my.ObjectState = Backbone.Model.extend({
    +});

    Backend registry

    + +

    Backends will register themselves by id into this registry

    my.backends = {};
     
     }(jQuery, this.recline.Model));
     
    -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/view-flot-graph.html b/docs/view-graph.html similarity index 63% rename from docs/view-flot-graph.html rename to docs/view-graph.html index 50035571..7e13d54b 100644 --- a/docs/view-flot-graph.html +++ b/docs/view-graph.html @@ -1,58 +1,28 @@ - - - - - view-flot-graph.js - - - -
    -
    -
    -

    view-flot-graph.js

    -
    -
    -
    -
    -
    - # -
    -

    jshint multistr:true

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

    view-graph.js

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

    Graph view for a Dataset using Flot graphing library.

    -

    Initialization arguments:

    +(function($, my) {

    Graph view for a Dataset using Flot graphing library.

    + +

    Initialization arguments (in a hash in first parameter):

    +
    • model: recline.Model.Dataset
    • -
    • -

      config: (optional) graph configuration hash of form:

      +
    • state: (optional) 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({
    +generate the element itself (you can then append view.el to the DOM.

    my.Graph = Backbone.View.extend({
     
       tagName:  "div",
    -  className: "data-graph-container",
    +  className: "recline-graph",
     
       template: ' \
       <div class="editor"> \
    @@ -115,69 +85,29 @@ generate the element itself (you can then append view.el to the DOM.

    'click .action-toggle-help': 'toggleHelp' }, - initialize: function(options, config) { + initialize: function(options) { var self = this; this.el = $(this.el); - _.bindAll(this, 'render', 'redraw');
    - - -
    -
    -
    -
    - # -
    -

    we need the model.fields to render properly

    -
    -
    -
        this.model.bind('change', this.render);
    +    _.bindAll(this, 'render', 'redraw');

    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({
    +    var stateData = _.extend({
             group: null,
             series: [],
             graphType: 'lines-and-points'
           },
    -      configFromHash,
    -      config
    -      );
    +      options.state
    +    );
    +    this.state = new recline.Model.ObjectState(stateData);
         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.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;
       },
    @@ -188,71 +118,28 @@ could be simpler just to have a common template!

    var series = this.$series.map(function () { return $(this).val(); }); - this.chartConfig.series = $.makeArray(series); - this.chartConfig.group = this.el.find('.editor-group select').val(); - this.chartConfig.graphType = this.el.find('.editor-type select').val();
    - - -
    -
    -
    -
    - # -
    -

    update navigation

    -
    -
    -
        var qs = my.parseHashQueryString();
    -    qs.graph = JSON.stringify(this.chartConfig);
    -    my.setHashQueryString(qs);
    +    var updatedState = {
    +      series: $.makeArray(series),
    +      group: this.el.find('.editor-group select').val(),
    +      graphType: this.el.find('.editor-type select').val()
    +    };
    +    this.state.set(updatedState);
         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]);
    +  redraw: function() {

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

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

      + +

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

    • +
    • There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
    • +
        var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
         if ((!areWeVisible || this.model.currentDocuments.length === 0)) {
           return;
         }
         var series = this.createSeries();
    -    var options = this.getGraphOptions(this.chartConfig.graphType);
    +    var options = this.getGraphOptions(this.state.attributes.graphType);
         this.plot = $.plot(this.$graph, series, options);
    -    this.setupTooltips();
    - - -
    -
    -
    -
    - # -
    -

    create this.plot and cache it + this.setupTooltips();

    create this.plot and cache it if (!this.plot) { this.plot = $.plot(this.$graph, series, options); } else { @@ -261,71 +148,20 @@ could be simpler just to have a common template!

    this.plot.resize(); this.plot.setupGrid(); this.plot.draw(); - }

    - -
    -
      },
    -
    - -
    -
    -
    -
    - # -
    -

    needs to be function as can depend on state

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

    special tickformatter to show labels rather than numbers

    -
    -
    -
        var tickFormatter = function (val) {
    +   }

      },

    needs to be function as can depend on state

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

    special tickformatter to show labels rather than numbers

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

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

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

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

            if (typeof(out) == 'number') {
               return val;
             } else {
               return out;
             }
           }
           return val;
    -    };
    - - -
    -
    -
    -
    - # -
    -

    TODO: we should really use tickFormatter and 1 interval ticks if (and + };

    TODO: we should really use tickFormatter and 1 interval ticks if (and only if) x-axis values are non-numeric However, that is non-trivial to work out from a dataset (datasets may -have no field type info). Thus at present we only do this for bars.

    - -
    -
        var options = { 
    +have no field type info). Thus at present we only do this for bars.

        var options = { 
           lines: {
              series: { 
                lines: { show: true }
    @@ -391,27 +227,15 @@ have no field type info). Thus at present we only do this for bars.

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

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

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

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

              if (self.model.currentDocuments.models[x]) {
    +            x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
               } else {
                 x = x.toFixed(2);
               }
               y = y.toFixed(2);
               
               var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
    -            group: self.chartConfig.group,
    +            group: self.state.attributes.group,
                 x: x,
                 series: item.series.label,
                 y: y
    @@ -429,52 +253,28 @@ have no field type info). Thus at present we only do this for bars.

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

    horizontal bar chart

    -
    -
    -
              if (self.chartConfig.graphType == 'bars') {
    -            points.push([y, x]);
    -          } else {
    -            points.push([x, y]);
    -          }
    -        });
    -        series.push({data: points, label: field});
    +    _.each(this.state.attributes.series, function(field) {
    +      var points = [];
    +      _.each(self.model.currentDocuments.models, function(doc, index) {
    +        var x = doc.get(self.state.attributes.group);
    +        var y = doc.get(field);
    +        if (typeof x === 'string') {
    +          x = index;
    +        }

    horizontal bar chart

            if (self.state.attributes.graphType == 'bars') {
    +          points.push([y, x]);
    +        } else {
    +          points.push([x, y]);
    +        }
           });
    -    }
    +      series.push({data: points, label: field});
    +    });
         return series;
    -  },
    - - -
    -
    -
    -
    - # -
    -

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

    + },

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

    +

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

    -

    Returns itself.

    - -
    -
      addSeries: function (e) {
    +
    +

    Returns itself.

      addSeries: function (e) {
         e.preventDefault();
         var element = this.$seriesClone.clone(),
             label   = element.find('label'),
    @@ -485,20 +285,9 @@ to be removed.

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

    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();
    @@ -514,29 +303,13 @@ to be removed.

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

    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-grid.html b/docs/view-grid.html index f354bcf0..7999d978 100644 --- a/docs/view-grid.html +++ b/docs/view-grid.html @@ -1,43 +1,13 @@ - - - - - view-grid.js - - - -
    -
    -
    -

    view-grid.js

    -
    -
    -
    -
    -
    - # -
    -

    jshint multistr:true

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

    view-grid.js

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

    DataGrid

    +(function($, my) {

    (Data) Grid Dataset View

    +

    Provides a tabular view on a Dataset.

    -

    Initialize it with a recline.Model.Dataset.

    - -
    -
    my.DataGrid = Backbone.View.extend({
    +
    +

    Initialize it with a recline.Model.Dataset.

    my.Grid = Backbone.View.extend({
       tagName:  "div",
       className: "recline-grid-container",
     
    @@ -48,25 +18,20 @@
         this.model.currentDocuments.bind('add', this.render);
         this.model.currentDocuments.bind('reset', this.render);
         this.model.currentDocuments.bind('remove', this.render);
    -    this.state = {};
    -    this.hiddenFields = [];
    +    this.tempState = {};
    +    var state = _.extend({
    +        hiddenFields: []
    +      }, modelEtc.state
    +    ); 
    +    this.state = new recline.Model.ObjectState(state);
       },
     
       events: {
    -    'click .column-header-menu': 'onColumnHeaderClick',
    +    'click .column-header-menu .data-table-menu li a': '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'); @@ -75,29 +40,13 @@ 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).closest('.column-header').attr('data-field');
    +},

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

      onColumnHeaderClick: function(e) {
    +    this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
       },
     
       onRowHeaderClick: function(e) {
    -    this.state.currentRow = $(e.target).parents('tr:first').attr('data-id');
    +    this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id');
       },
       
       onRootHeaderClick: function(e) {
    @@ -105,7 +54,7 @@ showDialog: function(template, data) {
             {{#columns}} \
             <li><a data-action="showColumn" data-column="{{.}}" href="JavaScript:void(0);">Show column: {{.}}</a></li> \
             {{/columns}}';
    -    var tmp = $.mustache(tmpl, {'columns': this.hiddenFields});
    +    var tmp = $.mustache(tmpl, {'columns': this.state.get('hiddenFields')});
         this.el.find('.root-header-menu .dropdown-menu').html(tmp);
       },
     
    @@ -113,15 +62,15 @@ showDialog: function(template, data) {
         var self = this;
         e.preventDefault();
         var actions = {
    -      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}); },
    +      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); },
           facet: function() { 
    -        self.model.queryState.addFacet(self.state.currentColumn);
    +        self.model.queryState.addFacet(self.tempState.currentColumn);
           },
           facet_histogram: function() {
    -        self.model.queryState.addHistogramFacet(self.state.currentColumn);
    +        self.model.queryState.addHistogramFacet(self.tempState.currentColumn);
           },
           filter: function() {
    -        self.model.queryState.addTermFilter(self.state.currentColumn, '');
    +        self.model.queryState.addTermFilter(self.tempState.currentColumn, '');
           },
           transform: function() { self.showTransformDialog('transform'); },
           sortAsc: function() { self.setColumnSort('asc'); },
    @@ -129,20 +78,8 @@ showDialog: function(template, data) {
           hideColumn: function() { self.hideColumn(); },
           showColumn: function() { self.showColumn(e); },
           deleteRow: function() {
    -        var doc = _.find(self.model.currentDocuments.models, function(doc) {
    - - -
    -
    -
    -
    - # -
    -

    important this is == as the currentRow will be string (as comes -from DOM) while id may be int

    -
    -
    -
              return doc.id == self.state.currentRow;
    +        var doc = _.find(self.model.currentDocuments.models, function(doc) {

    important this is == as the currentRow will be string (as comes +from DOM) while id may be int

              return doc.id == self.tempState.currentRow;
             });
             doc.destroy().then(function() { 
                 self.model.currentDocuments.remove(doc);
    @@ -161,7 +98,7 @@ from DOM) while id may be int

    var view = new my.ColumnTransform({ model: this.model }); - view.state = this.state; + view.state = this.tempState; view.render(); $el.empty(); $el.append(view.el); @@ -187,33 +124,24 @@ from DOM) while id may be int

    setColumnSort: function(order) { var sort = [{}]; - sort[0][this.state.currentColumn] = {order: order}; + sort[0][this.tempState.currentColumn] = {order: order}; this.model.query({sort: sort}); }, hideColumn: function() { - this.hiddenFields.push(this.state.currentColumn); + var hiddenFields = this.state.get('hiddenFields'); + hiddenFields.push(this.tempState.currentColumn); + this.state.set({hiddenFields: hiddenFields}); this.render(); }, showColumn: function(e) { - this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column')); + var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column')); + this.state.set({hiddenFields: hiddenFields}); this.render(); - },
    - - -
    -
    -
    -
    - # -
    -
    ################################################
    -

    Templating

    -

    -
    -
    -
      template: ' \
    +  },

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

    + +

    Templating

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

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

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

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

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

        modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); });
         return modelData;
       },
       render: function() {
         var self = this;
         this.fields = this.model.fields.filter(function(field) {
    -      return _.indexOf(self.hiddenFields, field.id) == -1;
    +      return _.indexOf(self.state.get('hiddenFields'), field.id) == -1;
         });
         var htmls = $.mustache(this.template, this.toTemplateJSON());
         this.el.html(htmls);
         this.model.currentDocuments.forEach(function(doc) {
           var tr = $('<tr />');
           self.el.find('tbody').append(tr);
    -      var newView = new my.DataGridRow({
    +      var newView = new my.GridRow({
               model: doc,
               el: tr,
               fields: self.fields
             });
           newView.render();
         });
    -    this.el.toggleClass('no-hidden', (self.hiddenFields.length === 0));
    +    this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
         return this;
       }
    -});
    - - -
    -
    -
    -
    - # -
    -

    DataGridRow View for rendering an individual document.

    +});

    GridRow View for rendering an individual document.

    +

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

    -

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

    + +

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

    +

    Example:

    +
    -var row = new DataGridRow({
    +var row = new GridRow({
       model: dataset-document,
         el: dom-element,
         fields: mydatasets.fields // a FieldList object
       });
    -
    - -
    -
    my.DataGridRow = Backbone.View.extend({
    +
    my.GridRow = Backbone.View.extend({
       initialize: function(initData) {
         _.bindAll(this, 'render');
         this._fields = initData.fields;
    @@ -361,21 +269,8 @@ var row = new DataGridRow({
         var html = $.mustache(this.template, this.toTemplateJSON());
         $(this.el).html(html);
         return this;
    -  },
    - - -
    -
    -
    -
    - # -
    -
    #############
    -

    Cell Editor methods -

    -
    -
    -
      onEditClick: function(e) {
    +  },

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

      onEditClick: function(e) {
         var editing = this.el.find('.data-table-cell-editor-editor');
         if (editing.length > 0) {
           editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden");
    @@ -414,9 +309,4 @@ var row = new DataGridRow({
     
     })(jQuery, recline.View);
     
    -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/view-map.html b/docs/view-map.html index 95dbf1aa..b865f6e7 100644 --- a/docs/view-map.html +++ b/docs/view-map.html @@ -1,60 +1,29 @@ - - - - - view-map.js - - - -
    -
    -
    -

    view-map.js

    -
    -
    -
    -
    -
    - # -
    -

    jshint multistr:true

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

    view-map.js

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

    Map view for a Dataset using Leaflet mapping library.

    +(function($, my) {

    Map view for a Dataset using Leaflet mapping library.

    +

    This view allows to plot gereferenced documents on a map. The location information can be provided either via a field with GeoJSON objects or two fields with latitude and longitude coordinates.

    -

    Initialization arguments:

    -
      -
    • -

      options: initial options. They must contain a model:

      -

      { - model: {recline.Model.Dataset} - }

      -
    • -
    • -

      config: (optional) map configuration hash (not yet used)

      -
    • -
    - -
    -
    my.Map = Backbone.View.extend({
    +
    +

    Initialization arguments are as standard for Dataset Views. State object may +have the following (optional) configuration options:

    + +
    +  {
    +    // geomField if specified will be used in preference to lat/lon 
    +    geomField: {id of field containing geometry in the dataset}
    +    lonField: {id of field containing longitude in the dataset}
    +    latField: {id of field containing latitude in the dataset}
    +  }
    +
    my.Map = Backbone.View.extend({
     
       tagName:  'div',
    -  className: 'data-map-container',
    +  className: 'recline-map',
     
       template: ' \
       <div class="editor"> \
    @@ -109,109 +78,46 @@ longitude coordinates.

    </div> \ <div class="panel map"> \ </div> \ -',
    - - -
    -
    -
    -
    - # -
    -

    These are the default field names that will be used if found. -If not found, the user will need to define the fields via the editor.

    -
    -
    -
      latitudeFieldNames: ['lat','latitude'],
    +',

    These are the default field names that will be used if found. +If not found, the user will need to define the fields via the editor.

      latitudeFieldNames: ['lat','latitude'],
       longitudeFieldNames: ['lon','longitude'],
    -  geometryFieldNames: ['geom','the_geom','geometry','spatial','location'],
    - - -
    -
    -
    -
    - # -
    -

    Define here events for UI elements

    -
    -
    -
      events: {
    +  geometryFieldNames: ['geom','the_geom','geometry','spatial','location'],

    Define here events for UI elements

      events: {
         'click .editor-update-map': 'onEditorSubmit',
         'change .editor-field-type': 'onFieldTypeChange'
       },
     
    -
    -  initialize: function(options, config) {
    +  initialize: function(options) {
         var self = this;
    -
    -    this.el = $(this.el);
    - - -
    -
    -
    -
    - # -
    -

    Listen to changes in the fields

    -
    -
    -
        this.model.bind('change', function() {
    +    this.el = $(this.el);

    Listen to changes in the fields

        this.model.fields.bind('change', function() {
           self._setupGeometryField();
         });
         this.model.fields.bind('add', this.render);
         this.model.fields.bind('reset', function(){
           self._setupGeometryField()
           self.render()
    -    });
    - - -
    -
    -
    -
    - # -
    -

    Listen to changes in the documents

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

    Listen to changes in the documents

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

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

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

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

        this.bind('view:show',function(){
    +        if (self.map) {
    +          self.map.invalidateSize();
    +        }
         });
     
    -    this.mapReady = false;
    +    var stateData = _.extend({
    +        geomField: null,
    +        lonField: null,
    +        latField: null
    +      },
    +      options.state
    +    );
    +    this.state = new recline.Model.ObjectState(stateData);
     
    +    this.mapReady = false;
         this.render();
    -  },
    - - -
    -
    -
    -
    - # -
    -

    Public: Adds the necessary elements to the page.

    -

    Also sets up the editor fields and the map if necessary.

    -
    -
    -
      render: function() {
    +  },

    Public: Adds the necessary elements to the page.

    + +

    Also sets up the editor fields and the map if necessary.

      render: function() {
     
         var self = this;
     
    @@ -221,12 +127,12 @@ to display properly

    this.$map = this.el.find('.panel.map'); if (this.geomReady && this.model.fields.length){ - if (this._geomFieldName){ - this._selectOption('editor-geom-field',this._geomFieldName); + if (this.state.get('geomField')){ + this._selectOption('editor-geom-field',this.state.get('geomField')); $('#editor-field-type-geom').attr('checked','checked').change(); } else{ - this._selectOption('editor-lon-field',this._lonFieldName); - this._selectOption('editor-lat-field',this._latFieldName); + this._selectOption('editor-lon-field',this.state.get('lonField')); + this._selectOption('editor-lat-field',this.state.get('latField')); $('#editor-field-type-latlon').attr('checked','checked').change(); } } @@ -243,29 +149,17 @@ to display properly

    }); return this; - },
    - - -
    -
    -
    -
    - # -
    -

    Public: Redraws the features on the map according to the action provided

    + },

    Public: Redraws the features on the map according to the action provided

    +

    Actions can be:

    +
    • reset: Clear all features
    • add: Add one or n features (documents)
    • remove: Remove one or n features (documents)
    • refresh: Clear existing features and add all current documents
    • -
    - -
    -
      redraw: function(action,doc){
    -
    +             
      redraw: function(action,doc){
         var self = this;
    -
         action = action || 'refresh';
     
         if (this.geomReady && this.mapReady){
    @@ -280,60 +174,30 @@ to display properly

    this._add(this.model.currentDocuments.models); } } - },
    - - -
    -
    -
    -
    - # -
    -

    UI Event handlers

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - # -
    -

    Public: Update map with user options

    + },

    UI Event handlers

    Public: Update map with user options

    +

    Right now the only configurable option is what field(s) contains the -location information.

    - -
    -
      onEditorSubmit: function(e){
    +location information.

      onEditorSubmit: function(e){
         e.preventDefault();
         if ($('#editor-field-type-geom').attr('checked')){
    -        this._geomFieldName = $('.editor-geom-field > select > option:selected').val();
    -        this._latFieldName = this._lonFieldName = false;
    +      this.state.set({
    +        geomField: $('.editor-geom-field > select > option:selected').val(),
    +        lonField: null,
    +        latField: null
    +      });
         } else {
    -        this._geomFieldName = false;
    -        this._latFieldName = $('.editor-lat-field > select > option:selected').val();
    -        this._lonFieldName = $('.editor-lon-field > select > option:selected').val();
    +      this.state.set({
    +        geomField: null,
    +        lonField: $('.editor-lon-field > select > option:selected').val(),
    +        latField: $('.editor-lat-field > select > option:selected').val()
    +      });
         }
    -    this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
    +    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
         this.redraw();
     
         return false;
    -  },
    - - -
    -
    -
    -
    - # -
    -

    Public: Shows the relevant select lists depending on the location field -type selected.

    -
    -
    -
      onFieldTypeChange: function(e){
    +  },

    Public: Shows the relevant select lists depending on the location field +type selected.

      onFieldTypeChange: function(e){
         if (e.target.value == 'geom'){
             $('.editor-field-type-geom').show();
             $('.editor-field-type-latlon').hide();
    @@ -341,61 +205,28 @@ type selected.

    $('.editor-field-type-geom').hide(); $('.editor-field-type-latlon').show(); } - },
    - - -
    -
    -
    -
    - # -
    -

    Private: Add one or n features to the map

    + },

    Private: Add one or n features to the map

    +

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

    -

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

    - -
    -
      _add: function(doc){
    +
    +

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

      _add: function(docs){
     
         var self = this;
     
    -    if (!(doc instanceof Array)) doc = [doc];
    +    if (!(docs instanceof Array)) docs = [docs];
     
    -    doc.forEach(function(doc){
    +    _.every(docs,function(doc){
           var feature = self._getGeometryFromDocument(doc);
    -      if (feature instanceof Object){
    - - -
    -
    -
    -
    - # -
    -

    Build popup contents -TODO: mustache?

    -
    -
    -
            html = ''
    +      if (typeof feature === 'undefined'){

    Empty field

            return true;
    +      } else if (feature instanceof Object){

    Build popup contents +TODO: mustache?

            html = ''
             for (key in doc.attributes){
               html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>'
             }
    -        feature.properties = {popupContent: html};
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
            feature.properties.cid = doc.cid;
    +        feature.properties = {popupContent: html};

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

            feature.properties.cid = doc.cid;
     
             try {
                 self.features.addGeoJSON(feature);
    @@ -403,32 +234,21 @@ link this Leaflet layer to a Recline doc

    var msg = 'Wrong geometry value'; if (except.message) msg += ' (' + except.message + ')'; my.notify(msg,{category:'error'}); - _.breakLoop(); + return false; } } else { my.notify('Wrong geometry value',{category:'error'}); - _.breakLoop(); + return false; } + return true; }); - },
    - - -
    -
    -
    -
    - # -
    -

    Private: Remove one or n features to the map

    -
    -
    -
      _remove: function(doc){
    +  },

    Private: Remove one or n features to the map

      _remove: function(docs){
     
         var self = this;
     
    -    if (!(doc instanceof Array)) doc = [doc];
    +    if (!(docs instanceof Array)) docs = [docs];
     
    -    doc.forEach(function(doc){
    +    _.each(docs,function(doc){
           for (key in self.features._layers){
             if (self.features._layers[key].cid == doc.cid){
               self.features.removeLayer(self.features._layers[key]);
    @@ -436,91 +256,32 @@ link this Leaflet layer to a Recline doc

    } }); - },
    - - -
    -
    -
    -
    - # -
    -

    Private: Return a GeoJSON geomtry extracted from the document fields

    -
    -
    -
      _getGeometryFromDocument: function(doc){
    +  },

    Private: Return a GeoJSON geomtry extracted from the document fields

      _getGeometryFromDocument: function(doc){
         if (this.geomReady){
    -      if (this._geomFieldName){
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
            return doc.attributes[this._geomFieldName];
    -      } else if (this._lonFieldName && this._latFieldName){
    -
    -
    -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
            return {
    +      if (this.state.get('geomField')){

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

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

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

            return {
               type: 'Point',
               coordinates: [
    -            doc.attributes[this._lonFieldName],
    -            doc.attributes[this._latFieldName]
    +            doc.attributes[this.state.get('lonField')],
    +            doc.attributes[this.state.get('latField')]
                 ]
             };
           }
           return null;
         }
    -  },
    - - -
    -
    -
    -
    - # -
    -

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

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

    -

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

    - -
    -
      _setupGeometryField: function(){
    +
    +

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

      _setupGeometryField: function(){
         var geomField, latField, lonField;
    -
    -    this._geomFieldName = this._checkField(this.geometryFieldNames);
    -    this._latFieldName = this._checkField(this.latitudeFieldNames);
    -    this._lonFieldName = this._checkField(this.longitudeFieldNames);
    -
    -    this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName));
    -  },
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
      _checkField: function(fieldNames){
    +    this.state.set({
    +      geomField: this._checkField(this.geometryFieldNames),
    +      latField: this._checkField(this.latitudeFieldNames),
    +      lonField: this._checkField(this.longitudeFieldNames)
    +    });
    +    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
    +  },

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

      _checkField: function(fieldNames){
         var field;
         var modelFieldNames = this.model.fields.pluck('id');
         for (var i = 0; i < fieldNames.length; i++){
    @@ -530,21 +291,10 @@ list of names.

    } } return null; - },
    - - -
    -
    -
    -
    - # -
    -

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

    + },

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

    +

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

    - -
    -
      _setupMap: function(){
    +on OpenStreetMap.

      _setupMap: function(){
     
         this.map = new L.Map(this.$map.get(0));
     
    @@ -568,19 +318,7 @@ on OpenStreetMap.

    this.map.setView(new L.LatLng(0, 0), 2); this.mapReady = true; - },
    - - -
    -
    -
    -
    - # -
    -

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

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

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

      _selectOption: function(id,value){
         var options = $('.' + id + ' > select > option');
         if (options){
           options.each(function(opt){
    @@ -596,9 +334,4 @@ on OpenStreetMap.

    })(jQuery, recline.View); -
    - - -
    - - +
    \ No newline at end of file diff --git a/docs/view.html b/docs/view.html index ce57a75f..0ca33a89 100644 --- a/docs/view.html +++ b/docs/view.html @@ -1,83 +1,149 @@ - - - - - view.js - - - -
    -
    -
    -

    view.js

    -
    -
    -
    -
    -
    - # -
    -

    jshint multistr:true

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

    view.js

    /*jshint multistr:true */

    Recline Views

    + +

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

    + +

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

    + +
      +
    • Dataset Views: a View intended for displaying a recline.Model.Dataset +in some fashion. Examples are the Grid, Graph and Map views.
    • +
    • Widget Views: a widget used for displaying some specific (and +smaller) aspect of a dataset or the application. Examples are +QueryEditor and FilterEditor which both provide a way for editing (a +part of) a recline.Model.Query associated to a Dataset.
    • +
    + +

    Dataset View

    + +

    These views are just Backbone views with a few additional conventions:

    + +
      +
    1. The model passed to the View should always be a recline.Model.Dataset instance
    2. +
    3. Views should generate their own root element rather than having it passed +in.
    4. +
    5. Views should apply a css class named 'recline-{view-name-lower-cased} to +the root element (and for all CSS for this view to be qualified using this +CSS class)
    6. +
    7. Read-only mode: CSS for this view should respect/utilize +recline-read-only class to trigger read-only behaviour (this class will +usually be set on some parent element of the view's root element.
    8. +
    9. State: state (configuration) information for the view should be stored on +an attribute named state that is an instance of a Backbone Model (or, more +speficially, be an instance of recline.Model.ObjectState). In addition, +a state attribute may be specified in the Hash passed to a View on +iniitialization and this information should be used to set the initial +state of the view.

      + +

      Example of state would be the set of fields being plotted in a graph +view.

      + +

      More information about State can be found below.

    10. +
    + +

    To summarize some of this, the initialize function for a Dataset View should +look like:

    + +
    +   initialize: {
    +       model: {a recline.Model.Dataset instance}
    +       // el: {do not specify - instead view should create}
    +       state: {(optional) Object / Hash specifying initial state}
    +       ...
    +   }
    +
    + +

    Note: Dataset Views in core Recline have a common layout on disk as +follows, where ViewName is the named of View class:

    + +
    +src/view-{lower-case-ViewName}.js
    +css/{lower-case-ViewName}.css
    +test/view-{lower-case-ViewName}.js
    +
    + +

    State

    + +

    State information exists in order to support state serialization into the +url or elsewhere and reloading of application from a stored state.

    + +

    State is available not only for individual views (as described above) but +for the dataset (e.g. the current query). For an example of pulling together +state from across multiple components see recline.View.DataExplorer.

    + +

    Writing your own Views

    + +

    See the existing Views.

    + +

    Standard JS module setup

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

    DataExplorer

    +(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}}
    +  views: {{dataset views}}
    +  state: {{state configuration -- see below}}
     });
    -
    +

    Parameters

    -

    model: (required) Dataset instance.

    -

    el: (required) DOM element.

    -

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

    + +

    model: (required) recline.model.Dataset instance.

    + +

    el: (required) DOM element to bind to. NB: the element already +being in the DOM is important for rendering of some subviews (e.g. +Graph).

    + +

    views: (optional) the dataset views (Grid, Graph etc) for +DataExplorer to show. This is an array of view hashes. If not provided +initialize with (recline.View.)Grid, Graph, and Map views (with obvious id +and labels!).

    +
     var views = [
       {
         id: 'grid', // used for routing
         label: 'Grid', // used for view switcher
    -    view: new recline.View.DataGrid({
    +    view: new recline.View.Grid({
           model: dataset
         })
       },
       {
         id: 'graph',
         label: 'Graph',
    -    view: new recline.View.FlotGraph({
    +    view: new recline.View.Graph({
           model: dataset
         })
       }
     ];
     
    -

    config: Config options like:

    -
      -
    • readOnly: true/false (default: false) value indicating whether to - operate in read-only mode (hiding all editing options).
    • -
    -

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

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

    state: standard state config for this view. This state is slightly + special as it includes config of many of the subviews.

    + +
    +state = {
    +    query: {dataset query state - see dataset.queryState object}
    +    view-{id1}: {view-state for this view}
    +    view-{id2}: {view-state for }
    +    ...
    +    // Explorer
    +    currentView: id of current view (defaults to first view if not specified)
    +    readOnly: (default: false) run in read-only mode
    +}
    +
    + +

    Note that at present we do not serialize information about the actual set +of views in use -- e.g. those specified by the views argument -- but instead +expect either that the default views are fine or that the client to have +initialized the DataExplorer with the relevant views themselves.

    my.DataExplorer = Backbone.View.extend({
       template: ' \
       <div class="recline-data-explorer"> \
         <div class="alert-messages"></div> \
    @@ -85,7 +151,7 @@ FlotGraph subview.

    <div class="header"> \ <ul class="navigation"> \ {{#views}} \ - <li><a href="#{{id}}" class="btn">{{label}}</a> \ + <li><a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \ {{/views}} \ </ul> \ <div class="recline-results-info"> \ @@ -108,53 +174,47 @@ FlotGraph subview.

    </div> \ ', events: { - 'click .menu-right a': 'onMenuClick' + 'click .menu-right a': '_onMenuClick', + 'click .navigation a': '_onSwitchView' }, initialize: function(options) { var self = this; - this.el = $(this.el); - this.config = _.extend({ - readOnly: false - }, - options.config); - if (this.config.readOnly) { - this.setReadOnly(); - }
    - - -
    -
    -
    -
    - # -
    -

    Hash of 'page' views (i.e. those for whole page) keyed by page name

    -
    -
    -
        if (options.views) {
    +    this.el = $(this.el);

    Hash of 'page' views (i.e. those for whole page) keyed by page name

        this._setupState(options.state);
    +    if (options.views) {
           this.pageViews = options.views;
         } else {
           this.pageViews = [{
             id: 'grid',
             label: 'Grid',
    -        view: new my.DataGrid({
    -            model: this.model
    -          })
    +        view: new my.Grid({
    +          model: this.model,
    +          state: this.state.get('view-grid')
    +        }),
    +      }, {
    +        id: 'graph',
    +        label: 'Graph',
    +        view: new my.Graph({
    +          model: this.model,
    +          state: this.state.get('view-graph')
    +        }),
    +      }, {
    +        id: 'map',
    +        label: 'Map',
    +        view: new my.Map({
    +          model: this.model,
    +          state: this.state.get('view-map')
    +        }),
           }];
    -    }
    - - -
    -
    -
    -
    - # -
    -

    this must be called after pageViews are created

    -
    -
    -
        this.render();
    +    }

    these must be called after pageViews are created

        this.render();
    +    this._bindStateChanges();

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

        if (this.state.get('readOnly')) {
    +      this.setReadOnly();
    +    }
    +    if (this.state.get('currentView')) {
    +      this.updateNav(this.state.get('currentView'));
    +    } else {
    +      this.updateNav(this.pageViews[0].id);
    +    }
     
         this.router = new Backbone.Router();
         this.setupRouting();
    @@ -165,23 +225,9 @@ FlotGraph subview.

    this.model.bind('query:done', function() { my.clearNotifications(); self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - my.notify('Data loaded', {category: 'success'});
    - - -
    -
    -
    -
    - # -
    -

    update navigation

    -
    -
    -
            var qs = my.parseHashQueryString();
    +        my.notify('Data loaded', {category: 'success'});

    update navigation

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

    self.router.navigate(out);

          });
         this.model.bind('query:fail', function(error) {
             my.clearNotifications();
             var msg = '';
    @@ -198,26 +244,10 @@ FlotGraph subview.

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

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

    -
    -
    -
        this.model.fetch()
    +      });

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

        this.model.fetch()
           .done(function(dataset) {
    -        var queryState = my.parseHashQueryString().reclineQuery;
    -        if (queryState) {
    -          queryState = JSON.parse(queryState);
    -        }
    -        self.model.query(queryState);
    +        self.model.query(self.state.get('query'));
           })
           .fail(function(error) {
             my.notify(error.message, {category: 'error', persist: true});
    @@ -225,12 +255,11 @@ note this.model and dataset returned are the same

    }, setReadOnly: function() { - this.el.addClass('read-only'); + this.el.addClass('recline-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); @@ -255,46 +284,24 @@ note this.model and dataset returned are the same

    }, setupRouting: function() { - var self = this;
    - - -
    -
    -
    -
    - # -
    -

    Default route

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

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

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

    show the specific page

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

    show the specific page

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

    }); }, - onMenuClick: function(e) { + _onMenuClick: function(e) { e.preventDefault(); var action = $(e.target).attr('data-action'); if (action === 'filters') { @@ -313,8 +320,60 @@ note this.model and dataset returned are the same

    } else if (action === 'facets') { this.$facetViewer.show(); } + }, + + _onSwitchView: function(e) { + e.preventDefault(); + var viewName = $(e.target).attr('data-view'); + this.updateNav(viewName); + this.state.set({currentView: viewName}); + },

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

    + +

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

    + +

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

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

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

        var qs = my.parseHashQueryString();
    +    var query = qs.reclineQuery;
    +    query = query ? JSON.parse(query) : self.model.queryState.toJSON();

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

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

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

        var stateData = _.extend({
    +        query: query,
    +        'view-graph': graphState,
    +        backend: this.model.backend.__type__,
    +        dataset: this.model.toJSON(),
    +        currentView: null,
    +        readOnly: false
    +      },
    +      initialState);
    +    this.state = new recline.Model.ObjectState(stateData);
    +  },
    +
    +  _bindStateChanges: function() {
    +    var self = this;

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

        this.model.queryState.bind('change', function() {
    +      self.state.set({query: self.model.queryState.toJSON()});
    +    });
    +    _.each(this.pageViews, function(pageView) {
    +      if (pageView.view.state && pageView.view.state.bind) {
    +        var update = {};
    +        update['view-' + pageView.id] = pageView.view.state.toJSON();
    +        self.state.set(update);
    +        pageView.view.state.bind('change', function() {
    +          var update = {};
    +          update['view-' + pageView.id] = pageView.view.state.toJSON();
    +          self.state.set(update);
    +        });
    +      }
    +    });
       }
    -});
    +});

    DataExplorer.restore

    + +

    Restore a DataExplorer instance from a serialized state including the associated dataset

    my.DataExplorer.restore = function(state) {
    +  var dataset = recline.Model.Dataset.restore(state);
    +  var explorer = new my.DataExplorer({
    +    model: dataset,
    +    state: state
    +  });
    +  return explorer;
    +}
     
     my.QueryEditor = Backbone.View.extend({
       className: 'recline-query-editor', 
    @@ -417,19 +476,7 @@ note this.model and dataset returned are the same

    this.render(); }, render: function() { - var tmplData = $.extend(true, {}, this.model.toJSON());
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
        tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
    +    var tmplData = $.extend(true, {}, this.model.toJSON());

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

        tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
           filter.id = idx;
           return filter;
         });
    @@ -446,19 +493,7 @@ note this.model and dataset returned are the same

    }; }); var out = $.mustache(this.template, tmplData); - this.el.html(out);
    - - -
    -
    -
    -
    - # -
    -

    are there actually any facets to show?

    -
    -
    -
        if (this.model.get('filters').length > 0) {
    +    this.el.html(out);

    are there actually any facets to show?

        if (this.model.get('filters').length > 0) {
           this.el.show();
         } else {
           this.el.hide();
    @@ -541,19 +576,7 @@ note this.model and dataset returned are the same

    return facet; }); var templated = $.mustache(this.template, tmplData); - this.el.html(templated);
    - - -
    -
    -
    -
    - # -
    -

    are there actually any facets to show?

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

    are there actually any facets to show?

        if (this.model.facets.length > 0) {
           this.el.show();
         } else {
           this.el.hide();
    @@ -569,31 +592,9 @@ note this.model and dataset returned are the same

    var value = $target.attr('data-value'); this.model.queryState.addTermFilter(fieldId, value); } -});
    - - -
    -
    -
    -
    - # -
    -

    Miscellaneous Utilities

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

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

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

    Miscellaneous Utilities

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

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

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

    query: parsed[2] || '' }; } -};
    - - -
    -
    -
    -
    - # -
    -

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

    -
    -
    -
    my.parseQueryString = function(q) {
    +};

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

    my.parseQueryString = function(q) {
       if (!q) {
         return {};
       }
    @@ -628,52 +617,19 @@ note this.model and dataset returned are the same

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

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

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

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

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

    Parse the query string out of the URL hash

    -
    -
    -
    my.parseHashQueryString = function() {
    +};

    Parse the query string out of the URL hash

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

    Compse a Query String

    -
    -
    -
    my.composeQueryString = function(queryParams) {
    +};

    Compse a Query String

    my.composeQueryString = function(queryParams) {
       var queryString = '?';
       var items = [];
       $.each(queryParams, function(key, value) {
    +    if (typeof(value) === 'object') {
    +      value = JSON.stringify(value);
    +    }
         items.push(key + '=' + value);
       });
       queryString += items.join('&');
    @@ -682,19 +638,7 @@ note this.model and dataset returned are the same

    my.getNewHashForQueryString = function(queryParams) { var queryPart = my.composeQueryString(queryParams); - if (window.location.hash) {
    - - -
    -
    -
    -
    - # -
    -

    slice(1) to remove # at start

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

    slice(1) to remove # at start

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

    my.setHashQueryString = function(queryParams) { window.location.hash = my.getNewHashForQueryString(queryParams); -};
    - - -
    -
    -
    -
    - # -
    -

    notify

    +};

    notify

    +

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

    +
    • category: warning (default), success, error
    • persist: if true alert is persistent, o/w hidden after 3s (default = false)
    • loader: if true show loading spinner
    • -
    - -
    -
    my.notify = function(message, options) {
    +             
    my.notify = function(message, options) {
       if (!options) options = {};
       var tmplData = _.extend({
         msg: message,
    @@ -728,7 +662,7 @@ note this.model and dataset returned are the same

    }, options); var _template = ' \ - <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \ + <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \ {{msg}} \ {{#loader}} \ <span class="notification-loader">&nbsp;</span> \ @@ -743,29 +677,13 @@ note this.model and dataset returned are the same

    }); }, 1000); } -};
    - - -
    -
    -
    -
    - # -
    -

    clearNotifications

    -

    Clear all existing notifications

    -
    -
    -
    my.clearNotifications = function() {
    +};

    clearNotifications

    + +

    Clear all existing notifications

    my.clearNotifications = function() {
       var $notifications = $('.recline-data-explorer .alert-messages .alert');
       $notifications.remove();
     };
     
     })(jQuery, recline.View);
     
    -
    - - -
    - - +
    \ No newline at end of file diff --git a/recline.js b/recline.js index 2871c6df..e404d78b 100644 --- a/recline.js +++ b/recline.js @@ -86,7 +86,7 @@ this.recline.Model = this.recline.Model || {}; // // @property {number} docCount: total number of documents in this dataset // -// @property {Backend} backend: the Backend (instance) for this Dataset +// @property {Backend} backend: the Backend (instance) for this Dataset. // // @property {Query} queryState: `Query` object which stores current // queryState. queryState may be edited by other components (e.g. a query @@ -96,14 +96,24 @@ this.recline.Model = this.recline.Model || {}; // Facets. my.Dataset = Backbone.Model.extend({ __type__: 'Dataset', + // ### initialize // // Sets up instance properties (see above) + // + // @param {Object} model: standard set of model attributes passed to Backbone models + // + // @param {Object or String} backend: Backend instance (see + // `recline.Backend.Base`) or a string specifying that instance. The + // string specifying may be a full class path e.g. + // 'recline.Backend.ElasticSearch' or a simple name e.g. + // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in + // recline.Backend module) initialize: function(model, backend) { _.bindAll(this, 'query'); this.backend = backend; - if (backend && backend.constructor == String) { - this.backend = my.backends[backend]; + if (typeof(backend) === 'string') { + this.backend = this._backendFromString(backend); } this.fields = new my.FieldList(); this.currentDocuments = new my.DocumentList(); @@ -167,9 +177,73 @@ my.Dataset = Backbone.Model.extend({ data.docCount = this.docCount; data.fields = this.fields.toJSON(); return data; + }, + + // ### _backendFromString(backendString) + // + // See backend argument to initialize for details + _backendFromString: function(backendString) { + var parts = backendString.split('.'); + // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class + var current = window; + for(ii=0;ii +// { +// backend: {backend type - i.e. value of dataset.backend.__type__} +// dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler } +// // convenience - if url provided and dataste not this be used as dataset url +// url: {dataset url} +// ... +// } +my.Dataset.restore = function(state) { + // hack-y - restoring a memory dataset does not mean much ... + var dataset = null; + if (state.url && !state.dataset) { + state.dataset = {url: state.url}; + } + if (state.backend === 'memory') { + dataset = recline.Backend.createDataset( + [{stub: 'this is a stub dataset because we do not restore memory datasets'}], + [], + state.dataset // metadata + ); + } else { + dataset = new recline.Model.Dataset( + state.dataset, + state.backend + ); + } + return dataset; +}; + // ## A Document (aka Row) // // A single entry or row in the dataset @@ -449,6 +523,13 @@ my.FacetList = Backbone.Collection.extend({ model: my.Facet }); +// ## Object State +// +// Convenience Backbone model for storing (configuration) state of objects like Views. +my.ObjectState = Backbone.Model.extend({ +}); + + // ## Backend registry // // Backends will register themselves by id into this registry @@ -618,10 +699,10 @@ this.recline.View = this.recline.View || {}; // ## Graph view for a Dataset using Flot graphing library. // -// Initialization arguments: +// Initialization arguments (in a hash in first parameter): // // * model: recline.Model.Dataset -// * config: (optional) graph configuration hash of form: +// * state: (optional) configuration hash of form: // // { // group: {column name for x-axis}, @@ -631,10 +712,10 @@ this.recline.View = this.recline.View || {}; // // 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({ +my.Graph = Backbone.View.extend({ tagName: "div", - className: "data-graph-container", + className: "recline-graph", template: ' \
    \ @@ -697,7 +778,7 @@ my.FlotGraph = Backbone.View.extend({ 'click .action-toggle-help': 'toggleHelp' }, - initialize: function(options, config) { + initialize: function(options) { var self = this; this.el = $(this.el); _.bindAll(this, 'render', 'redraw'); @@ -707,18 +788,14 @@ my.FlotGraph = Backbone.View.extend({ 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({ + var stateData = _.extend({ group: null, series: [], graphType: 'lines-and-points' }, - configFromHash, - config - ); + options.state + ); + this.state = new recline.Model.ObjectState(stateData); this.render(); }, @@ -740,13 +817,12 @@ my.FlotGraph = Backbone.View.extend({ var series = this.$series.map(function () { return $(this).val(); }); - this.chartConfig.series = $.makeArray(series); - this.chartConfig.group = this.el.find('.editor-group select').val(); - this.chartConfig.graphType = this.el.find('.editor-type select').val(); - // update navigation - var qs = my.parseHashQueryString(); - qs.graph = JSON.stringify(this.chartConfig); - my.setHashQueryString(qs); + var updatedState = { + series: $.makeArray(series), + group: this.el.find('.editor-group select').val(), + graphType: this.el.find('.editor-type select').val() + }; + this.state.set(updatedState); this.redraw(); }, @@ -762,7 +838,7 @@ my.FlotGraph = Backbone.View.extend({ return; } var series = this.createSeries(); - var options = this.getGraphOptions(this.chartConfig.graphType); + var options = this.getGraphOptions(this.state.attributes.graphType); this.plot = $.plot(this.$graph, series, options); this.setupTooltips(); // create this.plot and cache it @@ -783,7 +859,7 @@ my.FlotGraph = Backbone.View.extend({ // special tickformatter to show labels rather than numbers var tickFormatter = function (val) { if (self.model.currentDocuments.models[val]) { - var out = self.model.currentDocuments.models[val].get(self.chartConfig.group); + var out = self.model.currentDocuments.models[val].get(self.state.attributes.group); // if the value was in fact a number we want that not the if (typeof(out) == 'number') { return val; @@ -866,14 +942,14 @@ my.FlotGraph = Backbone.View.extend({ var y = item.datapoint[1]; // convert back from 'index' value on x-axis (e.g. in cases where non-number values) if (self.model.currentDocuments.models[x]) { - x = self.model.currentDocuments.models[x].get(self.chartConfig.group); + x = self.model.currentDocuments.models[x].get(self.state.attributes.group); } else { x = x.toFixed(2); } y = y.toFixed(2); var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { - group: self.chartConfig.group, + group: self.state.attributes.group, x: x, series: item.series.label, y: y @@ -891,25 +967,23 @@ my.FlotGraph = Backbone.View.extend({ 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; - } - // horizontal bar chart - if (self.chartConfig.graphType == 'bars') { - points.push([y, x]); - } else { - points.push([x, y]); - } - }); - series.push({data: points, label: field}); + _.each(this.state.attributes.series, function(field) { + var points = []; + _.each(self.model.currentDocuments.models, function(doc, index) { + var x = doc.get(self.state.attributes.group); + var y = doc.get(field); + if (typeof x === 'string') { + x = index; + } + // horizontal bar chart + if (self.state.attributes.graphType == 'bars') { + points.push([y, x]); + } else { + points.push([x, y]); + } }); - } + series.push({data: points, label: field}); + }); return series; }, @@ -969,12 +1043,12 @@ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { -// ## DataGrid +// ## (Data) Grid Dataset View // // Provides a tabular view on a Dataset. // // Initialize it with a `recline.Model.Dataset`. -my.DataGrid = Backbone.View.extend({ +my.Grid = Backbone.View.extend({ tagName: "div", className: "recline-grid-container", @@ -985,12 +1059,16 @@ my.DataGrid = Backbone.View.extend({ this.model.currentDocuments.bind('add', this.render); this.model.currentDocuments.bind('reset', this.render); this.model.currentDocuments.bind('remove', this.render); - this.state = {}; - this.hiddenFields = []; + this.tempState = {}; + var state = _.extend({ + hiddenFields: [] + }, modelEtc.state + ); + this.state = new recline.Model.ObjectState(state); }, events: { - 'click .column-header-menu': 'onColumnHeaderClick', + 'click .column-header-menu .data-table-menu li a': 'onColumnHeaderClick', 'click .row-header-menu': 'onRowHeaderClick', 'click .root-header-menu': 'onRootHeaderClick', 'click .data-table-menu li a': 'onMenuClick' @@ -1012,11 +1090,11 @@ my.DataGrid = Backbone.View.extend({ // Column and row menus onColumnHeaderClick: function(e) { - this.state.currentColumn = $(e.target).closest('.column-header').attr('data-field'); + this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field'); }, onRowHeaderClick: function(e) { - this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); + this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id'); }, onRootHeaderClick: function(e) { @@ -1024,7 +1102,7 @@ my.DataGrid = Backbone.View.extend({ {{#columns}} \
  • Show column: {{.}}
  • \ {{/columns}}'; - var tmp = $.mustache(tmpl, {'columns': this.hiddenFields}); + var tmp = $.mustache(tmpl, {'columns': this.state.get('hiddenFields')}); this.el.find('.root-header-menu .dropdown-menu').html(tmp); }, @@ -1032,15 +1110,15 @@ my.DataGrid = Backbone.View.extend({ var self = this; e.preventDefault(); var actions = { - bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}); }, + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); }, facet: function() { - self.model.queryState.addFacet(self.state.currentColumn); + self.model.queryState.addFacet(self.tempState.currentColumn); }, facet_histogram: function() { - self.model.queryState.addHistogramFacet(self.state.currentColumn); + self.model.queryState.addHistogramFacet(self.tempState.currentColumn); }, filter: function() { - self.model.queryState.addTermFilter(self.state.currentColumn, ''); + self.model.queryState.addTermFilter(self.tempState.currentColumn, ''); }, transform: function() { self.showTransformDialog('transform'); }, sortAsc: function() { self.setColumnSort('asc'); }, @@ -1051,7 +1129,7 @@ my.DataGrid = Backbone.View.extend({ var doc = _.find(self.model.currentDocuments.models, function(doc) { // important this is == as the currentRow will be string (as comes // from DOM) while id may be int - return doc.id == self.state.currentRow; + return doc.id == self.tempState.currentRow; }); doc.destroy().then(function() { self.model.currentDocuments.remove(doc); @@ -1070,7 +1148,7 @@ my.DataGrid = Backbone.View.extend({ var view = new my.ColumnTransform({ model: this.model }); - view.state = this.state; + view.state = this.tempState; view.render(); $el.empty(); $el.append(view.el); @@ -1096,17 +1174,20 @@ my.DataGrid = Backbone.View.extend({ setColumnSort: function(order) { var sort = [{}]; - sort[0][this.state.currentColumn] = {order: order}; + sort[0][this.tempState.currentColumn] = {order: order}; this.model.query({sort: sort}); }, hideColumn: function() { - this.hiddenFields.push(this.state.currentColumn); + var hiddenFields = this.state.get('hiddenFields'); + hiddenFields.push(this.tempState.currentColumn); + this.state.set({hiddenFields: hiddenFields}); this.render(); }, showColumn: function(e) { - this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column')); + var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column')); + this.state.set({hiddenFields: hiddenFields}); this.render(); }, @@ -1162,41 +1243,41 @@ my.DataGrid = Backbone.View.extend({ render: function() { var self = this; this.fields = this.model.fields.filter(function(field) { - return _.indexOf(self.hiddenFields, field.id) == -1; + return _.indexOf(self.state.get('hiddenFields'), field.id) == -1; }); var htmls = $.mustache(this.template, this.toTemplateJSON()); this.el.html(htmls); this.model.currentDocuments.forEach(function(doc) { var tr = $(''); self.el.find('tbody').append(tr); - var newView = new my.DataGridRow({ + var newView = new my.GridRow({ model: doc, el: tr, fields: self.fields }); newView.render(); }); - this.el.toggleClass('no-hidden', (self.hiddenFields.length === 0)); + this.el.toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); return this; } }); -// ## DataGridRow View for rendering an individual document. +// ## GridRow View for rendering an individual document. // // Since we want this to update in place it is up to creator to provider the element to attach to. // -// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the DataGrid. +// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the Grid. // // Example: // //
    -// var row = new DataGridRow({
    +// var row = new GridRow({
     //   model: dataset-document,
     //     el: dom-element,
     //     fields: mydatasets.fields // a FieldList object
     //   });
     // 
    -my.DataGridRow = Backbone.View.extend({ +my.GridRow = Backbone.View.extend({ initialize: function(initData) { _.bindAll(this, 'render'); this._fields = initData.fields; @@ -1301,21 +1382,21 @@ this.recline.View = this.recline.View || {}; // [GeoJSON](http://geojson.org) objects or two fields with latitude and // longitude coordinates. // -// Initialization arguments: -// -// * options: initial options. They must contain a model: -// -// { -// model: {recline.Model.Dataset} -// } -// -// * config: (optional) map configuration hash (not yet used) -// +// Initialization arguments are as standard for Dataset Views. State object may +// have the following (optional) configuration options: // +//
    +//   {
    +//     // geomField if specified will be used in preference to lat/lon 
    +//     geomField: {id of field containing geometry in the dataset}
    +//     lonField: {id of field containing longitude in the dataset}
    +//     latField: {id of field containing latitude in the dataset}
    +//   }
    +// 
    my.Map = Backbone.View.extend({ tagName: 'div', - className: 'data-map-container', + className: 'recline-map', template: ' \
    \ @@ -1384,14 +1465,12 @@ my.Map = Backbone.View.extend({ 'change .editor-field-type': 'onFieldTypeChange' }, - - initialize: function(options, config) { + initialize: function(options) { var self = this; - this.el = $(this.el); // Listen to changes in the fields - this.model.bind('change', function() { + this.model.fields.bind('change', function() { self._setupGeometryField(); }); this.model.fields.bind('add', this.render); @@ -1408,11 +1487,21 @@ my.Map = Backbone.View.extend({ // If the div was hidden, Leaflet needs to recalculate some sizes // to display properly this.bind('view:show',function(){ - self.map.invalidateSize(); + if (self.map) { + self.map.invalidateSize(); + } }); - this.mapReady = false; + var stateData = _.extend({ + geomField: null, + lonField: null, + latField: null + }, + options.state + ); + this.state = new recline.Model.ObjectState(stateData); + this.mapReady = false; this.render(); }, @@ -1429,12 +1518,12 @@ my.Map = Backbone.View.extend({ this.$map = this.el.find('.panel.map'); if (this.geomReady && this.model.fields.length){ - if (this._geomFieldName){ - this._selectOption('editor-geom-field',this._geomFieldName); + if (this.state.get('geomField')){ + this._selectOption('editor-geom-field',this.state.get('geomField')); $('#editor-field-type-geom').attr('checked','checked').change(); } else{ - this._selectOption('editor-lon-field',this._lonFieldName); - this._selectOption('editor-lat-field',this._latFieldName); + this._selectOption('editor-lon-field',this.state.get('lonField')); + this._selectOption('editor-lat-field',this.state.get('latField')); $('#editor-field-type-latlon').attr('checked','checked').change(); } } @@ -1463,9 +1552,7 @@ my.Map = Backbone.View.extend({ // * refresh: Clear existing features and add all current documents // redraw: function(action,doc){ - var self = this; - action = action || 'refresh'; if (this.geomReady && this.mapReady){ @@ -1494,14 +1581,19 @@ my.Map = Backbone.View.extend({ onEditorSubmit: function(e){ e.preventDefault(); if ($('#editor-field-type-geom').attr('checked')){ - this._geomFieldName = $('.editor-geom-field > select > option:selected').val(); - this._latFieldName = this._lonFieldName = false; + this.state.set({ + geomField: $('.editor-geom-field > select > option:selected').val(), + lonField: null, + latField: null + }); } else { - this._geomFieldName = false; - this._latFieldName = $('.editor-lat-field > select > option:selected').val(); - this._lonFieldName = $('.editor-lon-field > select > option:selected').val(); + this.state.set({ + geomField: null, + lonField: $('.editor-lon-field > select > option:selected').val(), + latField: $('.editor-lat-field > select > option:selected').val() + }); } - this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName)); + this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); this.redraw(); return false; @@ -1576,7 +1668,7 @@ my.Map = Backbone.View.extend({ if (!(docs instanceof Array)) docs = [docs]; - _.each(doc,function(doc){ + _.each(docs,function(doc){ for (key in self.features._layers){ if (self.features._layers[key].cid == doc.cid){ self.features.removeLayer(self.features._layers[key]); @@ -1590,16 +1682,16 @@ my.Map = Backbone.View.extend({ // _getGeometryFromDocument: function(doc){ if (this.geomReady){ - if (this._geomFieldName){ + if (this.state.get('geomField')){ // We assume that the contents of the field are a valid GeoJSON object - return doc.attributes[this._geomFieldName]; - } else if (this._lonFieldName && this._latFieldName){ + return doc.attributes[this.state.get('geomField')]; + } else if (this.state.get('lonField') && this.state.get('latField')){ // We'll create a GeoJSON like point object from the two lat/lon fields return { type: 'Point', coordinates: [ - doc.attributes[this._lonFieldName], - doc.attributes[this._latFieldName] + doc.attributes[this.state.get('lonField')], + doc.attributes[this.state.get('latField')] ] }; } @@ -1613,12 +1705,12 @@ my.Map = Backbone.View.extend({ // If not found, the user can define them via the UI form. _setupGeometryField: function(){ var geomField, latField, lonField; - - this._geomFieldName = this._checkField(this.geometryFieldNames); - this._latFieldName = this._checkField(this.latitudeFieldNames); - this._lonFieldName = this._checkField(this.longitudeFieldNames); - - this.geomReady = (this._geomFieldName || (this._latFieldName && this._lonFieldName)); + this.state.set({ + geomField: this._checkField(this.geometryFieldNames), + latField: this._checkField(this.latitudeFieldNames), + lonField: this._checkField(this.longitudeFieldNames) + }); + this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); }, // Private: Check if a field in the current model exists in the provided @@ -1895,6 +1987,85 @@ my.ColumnTransform = Backbone.View.extend({ })(jQuery, recline.View); /*jshint multistr:true */ + +// # Recline Views +// +// Recline Views are Backbone Views and in keeping with normal Backbone views +// are Widgets / Components displaying something in the DOM. Like all Backbone +// views they have a pointer to a model or a collection and is bound to an +// element. +// +// Views provided by core Recline are crudely divided into two types: +// +// * Dataset Views: a View intended for displaying a recline.Model.Dataset +// in some fashion. Examples are the Grid, Graph and Map views. +// * Widget Views: a widget used for displaying some specific (and +// smaller) aspect of a dataset or the application. Examples are +// QueryEditor and FilterEditor which both provide a way for editing (a +// part of) a `recline.Model.Query` associated to a Dataset. +// +// ## Dataset View +// +// These views are just Backbone views with a few additional conventions: +// +// 1. The model passed to the View should always be a recline.Model.Dataset instance +// 2. Views should generate their own root element rather than having it passed +// in. +// 3. Views should apply a css class named 'recline-{view-name-lower-cased} to +// the root element (and for all CSS for this view to be qualified using this +// CSS class) +// 4. Read-only mode: CSS for this view should respect/utilize +// recline-read-only class to trigger read-only behaviour (this class will +// usually be set on some parent element of the view's root element. +// 5. State: state (configuration) information for the view should be stored on +// an attribute named state that is an instance of a Backbone Model (or, more +// speficially, be an instance of `recline.Model.ObjectState`). In addition, +// a state attribute may be specified in the Hash passed to a View on +// iniitialization and this information should be used to set the initial +// state of the view. +// +// Example of state would be the set of fields being plotted in a graph +// view. +// +// More information about State can be found below. +// +// To summarize some of this, the initialize function for a Dataset View should +// look like: +// +//
    +//    initialize: {
    +//        model: {a recline.Model.Dataset instance}
    +//        // el: {do not specify - instead view should create}
    +//        state: {(optional) Object / Hash specifying initial state}
    +//        ...
    +//    }
    +// 
    +// +// Note: Dataset Views in core Recline have a common layout on disk as +// follows, where ViewName is the named of View class: +// +//
    +// src/view-{lower-case-ViewName}.js
    +// css/{lower-case-ViewName}.css
    +// test/view-{lower-case-ViewName}.js
    +// 
    +// +// ### State +// +// State information exists in order to support state serialization into the +// url or elsewhere and reloading of application from a stored state. +// +// State is available not only for individual views (as described above) but +// for the dataset (e.g. the current query). For an example of pulling together +// state from across multiple components see `recline.View.DataExplorer`. +// +// ### Writing your own Views +// +// See the existing Views. +// +// ---- + +// Standard JS module setup this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -1907,47 +2078,62 @@ this.recline.View = this.recline.View || {}; // var myExplorer = new model.recline.DataExplorer({ // model: {{recline.Model.Dataset instance}} // el: {{an existing dom element}} -// views: {{page views}} -// config: {{config options -- see below}} +// views: {{dataset views}} +// state: {{state configuration -- see below}} // }); //
    // // ### Parameters // -// **model**: (required) Dataset instance. +// **model**: (required) recline.model.Dataset instance. // -// **el**: (required) DOM element. +// **el**: (required) DOM element to bind to. NB: the element already +// being in the DOM is important for rendering of some subviews (e.g. +// Graph). // -// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to -// show. This is an array of view hashes. If not provided -// just initialize a DataGrid with id 'grid'. Example: +// **views**: (optional) the dataset views (Grid, Graph etc) for +// DataExplorer to show. This is an array of view hashes. If not provided +// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id +// and labels!). // //
     // var views = [
     //   {
     //     id: 'grid', // used for routing
     //     label: 'Grid', // used for view switcher
    -//     view: new recline.View.DataGrid({
    +//     view: new recline.View.Grid({
     //       model: dataset
     //     })
     //   },
     //   {
     //     id: 'graph',
     //     label: 'Graph',
    -//     view: new recline.View.FlotGraph({
    +//     view: new recline.View.Graph({
     //       model: dataset
     //     })
     //   }
     // ];
     // 
    // -// **config**: Config options like: +// **state**: standard state config for this view. This state is slightly +// special as it includes config of many of the subviews. // -// * readOnly: true/false (default: false) value indicating whether to -// operate in read-only mode (hiding all editing options). +//
    +// state = {
    +//     query: {dataset query state - see dataset.queryState object}
    +//     view-{id1}: {view-state for this view}
    +//     view-{id2}: {view-state for }
    +//     ...
    +//     // Explorer
    +//     currentView: id of current view (defaults to first view if not specified)
    +//     readOnly: (default: false) run in read-only mode
    +// }
    +// 
    // -// NB: the element already being in the DOM is important for rendering of -// FlotGraph subview. +// Note that at present we do *not* serialize information about the actual set +// of views in use -- e.g. those specified by the views argument -- but instead +// expect either that the default views are fine or that the client to have +// initialized the DataExplorer with the relevant views themselves. my.DataExplorer = Backbone.View.extend({ template: ' \
    \ @@ -1956,7 +2142,7 @@ my.DataExplorer = Backbone.View.extend({
    \ \
    \ @@ -1979,33 +2165,53 @@ my.DataExplorer = Backbone.View.extend({
    \ ', events: { - 'click .menu-right a': 'onMenuClick' + 'click .menu-right a': '_onMenuClick', + 'click .navigation a': '_onSwitchView' }, initialize: function(options) { var self = this; this.el = $(this.el); - this.config = _.extend({ - readOnly: false - }, - options.config); - if (this.config.readOnly) { - this.setReadOnly(); - } // Hash of 'page' views (i.e. those for whole page) keyed by page name + this._setupState(options.state); if (options.views) { this.pageViews = options.views; } else { this.pageViews = [{ id: 'grid', label: 'Grid', - view: new my.DataGrid({ - model: this.model - }) + view: new my.Grid({ + model: this.model, + state: this.state.get('view-grid') + }), + }, { + id: 'graph', + label: 'Graph', + view: new my.Graph({ + model: this.model, + state: this.state.get('view-graph') + }), + }, { + id: 'map', + label: 'Map', + view: new my.Map({ + model: this.model, + state: this.state.get('view-map') + }), }]; } - // this must be called after pageViews are created + // these must be called after pageViews are created this.render(); + this._bindStateChanges(); + // now do updates based on state (need to come after render) + if (this.state.get('readOnly')) { + this.setReadOnly(); + } + if (this.state.get('currentView')) { + this.updateNav(this.state.get('currentView')); + } else { + this.updateNav(this.pageViews[0].id); + } this.router = new Backbone.Router(); this.setupRouting(); @@ -2021,7 +2227,7 @@ my.DataExplorer = Backbone.View.extend({ var qs = my.parseHashQueryString(); qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON()); var out = my.getNewHashForQueryString(qs); - self.router.navigate(out); + // self.router.navigate(out); }); this.model.bind('query:fail', function(error) { my.clearNotifications(); @@ -2045,11 +2251,7 @@ my.DataExplorer = Backbone.View.extend({ // note this.model and dataset returned are the same this.model.fetch() .done(function(dataset) { - var queryState = my.parseHashQueryString().reclineQuery; - if (queryState) { - queryState = JSON.parse(queryState); - } - self.model.query(queryState); + self.model.query(self.state.get('query')); }) .fail(function(error) { my.notify(error.message, {category: 'error', persist: true}); @@ -2057,12 +2259,11 @@ my.DataExplorer = Backbone.View.extend({ }, setReadOnly: function() { - this.el.addClass('read-only'); + this.el.addClass('recline-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); @@ -2089,20 +2290,22 @@ my.DataExplorer = Backbone.View.extend({ setupRouting: function() { var self = this; // Default route - this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) { - self.updateNav(self.pageViews[0].id, queryString); - }); - $.each(this.pageViews, function(idx, view) { - self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { - self.updateNav(viewId, queryString); - }); +// this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) { +// self.updateNav(self.pageViews[0].id, queryString); +// }); +// $.each(this.pageViews, function(idx, view) { +// self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) { +// self.updateNav(viewId, queryString); +// }); +// }); + this.router.route(/.*/, 'view', function() { }); }, - updateNav: function(pageName, queryString) { + updateNav: function(pageName) { this.el.find('.navigation li').removeClass('active'); this.el.find('.navigation li a').removeClass('disabled'); - var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); + var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]'); $el.parent().addClass('active'); $el.addClass('disabled'); // show the specific page @@ -2117,7 +2320,7 @@ my.DataExplorer = Backbone.View.extend({ }); }, - onMenuClick: function(e) { + _onMenuClick: function(e) { e.preventDefault(); var action = $(e.target).attr('data-action'); if (action === 'filters') { @@ -2125,9 +2328,76 @@ my.DataExplorer = Backbone.View.extend({ } else if (action === 'facets') { this.$facetViewer.show(); } + }, + + _onSwitchView: function(e) { + e.preventDefault(); + var viewName = $(e.target).attr('data-view'); + this.updateNav(viewName); + this.state.set({currentView: viewName}); + }, + + // create a state object for this view and do the job of + // + // a) initializing it from both data passed in and other sources (e.g. hash url) + // + // b) ensure the state object is updated in responese to changes in subviews, query etc. + _setupState: function(initialState) { + var self = this; + // get data from the query string / hash url plus some defaults + var qs = my.parseHashQueryString(); + var query = qs.reclineQuery; + query = query ? JSON.parse(query) : self.model.queryState.toJSON(); + // backwards compatability (now named view-graph but was named graph) + var graphState = qs['view-graph'] || qs.graph; + graphState = graphState ? JSON.parse(graphState) : {}; + + // now get default data + hash url plus initial state and initial our state object with it + var stateData = _.extend({ + query: query, + 'view-graph': graphState, + backend: this.model.backend.__type__, + dataset: this.model.toJSON(), + currentView: null, + readOnly: false + }, + initialState); + this.state = new recline.Model.ObjectState(stateData); + }, + + _bindStateChanges: function() { + var self = this; + // finally ensure we update our state object when state of sub-object changes so that state is always up to date + this.model.queryState.bind('change', function() { + self.state.set({query: self.model.queryState.toJSON()}); + }); + _.each(this.pageViews, function(pageView) { + if (pageView.view.state && pageView.view.state.bind) { + var update = {}; + update['view-' + pageView.id] = pageView.view.state.toJSON(); + self.state.set(update); + pageView.view.state.bind('change', function() { + var update = {}; + update['view-' + pageView.id] = pageView.view.state.toJSON(); + self.state.set(update); + }); + } + }); } }); +// ### DataExplorer.restore +// +// Restore a DataExplorer instance from a serialized state including the associated dataset +my.DataExplorer.restore = function(state) { + var dataset = recline.Model.Dataset.restore(state); + var explorer = new my.DataExplorer({ + model: dataset, + state: state + }); + return explorer; +} + my.QueryEditor = Backbone.View.extend({ className: 'recline-query-editor', template: ' \ @@ -2403,6 +2673,9 @@ my.composeQueryString = function(queryParams) { var queryString = '?'; var items = []; $.each(queryParams, function(key, value) { + if (typeof(value) === 'object') { + value = JSON.stringify(value); + } items.push(key + '=' + value); }); queryString += items.join('&'); @@ -2484,10 +2757,20 @@ this.recline.Backend = this.recline.Backend || {}; // ## recline.Backend.Base // // Base class for backends providing a template and convenience functions. - // You do not have to inherit from this class but even when not it does provide guidance on the functions you must implement. + // You do not have to inherit from this class but even when not it does + // provide guidance on the functions you must implement. // // Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience. my.Base = Backbone.Model.extend({ + // ### __type__ + // + // 'type' of this backend. This should be either the class path for this + // object as a string (e.g. recline.Backend.Memory) or for Backends within + // recline.Backend module it may be their class name. + // + // This value is used as an identifier for this backend when initializing + // backends (see recline.Model.Dataset.initialize). + __type__: 'base', // ### sync // @@ -2607,6 +2890,7 @@ this.recline.Backend = this.recline.Backend || {}; // // Note that this is a **read-only** backend. my.DataProxy = my.Base.extend({ + __type__: 'dataproxy', defaults: { dataproxy_url: 'http://jsonpdataproxy.appspot.com' }, @@ -2661,8 +2945,6 @@ this.recline.Backend = this.recline.Backend || {}; return dfd.promise(); } }); - recline.Model.backends['dataproxy'] = new my.DataProxy(); - }(jQuery, this.recline.Backend)); this.recline = this.recline || {}; @@ -2687,6 +2969,7 @@ this.recline.Backend = this.recline.Backend || {}; // //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ + __type__: 'elasticsearch', _getESUrl: function(dataset) { var out = dataset.get('elasticsearch_url'); if (out) return out; @@ -2782,7 +3065,6 @@ this.recline.Backend = this.recline.Backend || {}; return dfd.promise(); } }); - recline.Model.backends['elasticsearch'] = new my.ElasticSearch(); }(jQuery, this.recline.Backend)); @@ -2805,6 +3087,7 @@ this.recline.Backend = this.recline.Backend || {}; // ); //
    my.GDoc = my.Base.extend({ + __type__: 'gdoc', getUrl: function(dataset) { var url = dataset.get('url'); if (url.indexOf('feeds/list') != -1) { @@ -2922,7 +3205,6 @@ this.recline.Backend = this.recline.Backend || {}; return results; } }); - recline.Model.backends['gdocs'] = new my.GDoc(); }(jQuery, this.recline.Backend)); @@ -2930,7 +3212,9 @@ this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; (function($, my) { - my.loadFromCSVFile = function(file, callback) { + my.loadFromCSVFile = function(file, callback, options) { + var encoding = options.encoding || 'UTF-8'; + var metadata = { id: file.name, file: file @@ -2938,17 +3222,17 @@ this.recline.Backend = this.recline.Backend || {}; var reader = new FileReader(); // TODO reader.onload = function(e) { - var dataset = my.csvToDataset(e.target.result); + var dataset = my.csvToDataset(e.target.result, options); callback(dataset); }; reader.onerror = function (e) { alert('Failed to load file. Code: ' + e.target.error.code); }; - reader.readAsText(file); + reader.readAsText(file, encoding); }; - my.csvToDataset = function(csvString) { - var out = my.parseCSV(csvString); + my.csvToDataset = function(csvString, options) { + var out = my.parseCSV(csvString, options); fields = _.map(out[0], function(cell) { return { id: cell, label: cell }; }); @@ -2963,128 +3247,133 @@ this.recline.Backend = this.recline.Backend || {}; return dataset; }; - // Converts a Comma Separated Values string into an array of arrays. - // Each line in the CSV becomes an array. + // Converts a Comma Separated Values string into an array of arrays. + // Each line in the CSV becomes an array. // - // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats. - // - // @return The CSV parsed as an array - // @type Array - // - // @param {String} s The string to convert - // @param {Boolean} [trm=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported + // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats. // + // @return The CSV parsed as an array + // @type Array + // + // @param {String} s The string to convert + // @param {Object} options Options for loading CSV including + // @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported + // @param {String} [separator=','] Separator for CSV file // Heavily based on uselesscode's JS CSV parser (MIT Licensed): // thttp://www.uselesscode.org/javascript/csv/ - my.parseCSV= function(s, trm) { - // Get rid of any trailing \n - s = chomp(s); + my.parseCSV= function(s, options) { + // Get rid of any trailing \n + s = chomp(s); - var cur = '', // The character we are currently processing. - inQuote = false, - fieldQuoted = false, - field = '', // Buffer for building up the current field - row = [], - out = [], - i, - processField; + var options = options || {}; + var trm = options.trim; + var separator = options.separator || ','; + + var cur = '', // The character we are currently processing. + inQuote = false, + fieldQuoted = false, + field = '', // Buffer for building up the current field + row = [], + out = [], + i, + processField; - processField = function (field) { - if (fieldQuoted !== true) { - // If field is empty set to null - if (field === '') { - field = null; - // If the field was not quoted and we are trimming fields, trim it - } else if (trm === true) { - field = trim(field); - } + processField = function (field) { + if (fieldQuoted !== true) { + // If field is empty set to null + if (field === '') { + field = null; + // If the field was not quoted and we are trimming fields, trim it + } else if (trm === true) { + field = trim(field); + } - // Convert unquoted numbers to their appropriate types - if (rxIsInt.test(field)) { - field = parseInt(field, 10); - } else if (rxIsFloat.test(field)) { - field = parseFloat(field, 10); - } - } - return field; - }; + // Convert unquoted numbers to their appropriate types + if (rxIsInt.test(field)) { + field = parseInt(field, 10); + } else if (rxIsFloat.test(field)) { + field = parseFloat(field, 10); + } + } + return field; + }; - for (i = 0; i < s.length; i += 1) { - cur = s.charAt(i); + for (i = 0; i < s.length; i += 1) { + cur = s.charAt(i); - // If we are at a EOF or EOR - if (inQuote === false && (cur === ',' || cur === "\n")) { - field = processField(field); - // Add the current field to the current row - row.push(field); - // If this is EOR append row to output and flush row - if (cur === "\n") { - out.push(row); - row = []; - } - // Flush the field buffer - field = ''; - fieldQuoted = false; - } else { - // If it's not a ", add it to the field buffer - if (cur !== '"') { - field += cur; - } else { - if (!inQuote) { - // We are not in a quote, start a quote - inQuote = true; - fieldQuoted = true; - } else { - // Next char is ", this is an escaped " - if (s.charAt(i + 1) === '"') { - field += '"'; - // Skip the next char - i += 1; - } else { - // It's not escaping, so end quote - inQuote = false; - } - } - } - } - } + // If we are at a EOF or EOR + if (inQuote === false && (cur === separator || cur === "\n")) { + field = processField(field); + // Add the current field to the current row + row.push(field); + // If this is EOR append row to output and flush row + if (cur === "\n") { + out.push(row); + row = []; + } + // Flush the field buffer + field = ''; + fieldQuoted = false; + } else { + // If it's not a ", add it to the field buffer + if (cur !== '"') { + field += cur; + } else { + if (!inQuote) { + // We are not in a quote, start a quote + inQuote = true; + fieldQuoted = true; + } else { + // Next char is ", this is an escaped " + if (s.charAt(i + 1) === '"') { + field += '"'; + // Skip the next char + i += 1; + } else { + // It's not escaping, so end quote + inQuote = false; + } + } + } + } + } - // Add the last field - field = processField(field); - row.push(field); - out.push(row); + // Add the last field + field = processField(field); + row.push(field); + out.push(row); - return out; - }; + return out; + }; - var rxIsInt = /^\d+$/, - rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, - // If a string has leading or trailing space, - // contains a comma double quote or a newline - // it needs to be quoted in CSV output - rxNeedsQuoting = /^\s|\s$|,|"|\n/, - trim = (function () { - // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists - if (String.prototype.trim) { - return function (s) { - return s.trim(); - }; - } else { - return function (s) { - return s.replace(/^\s*/, '').replace(/\s*$/, ''); - }; - } - }()); + var rxIsInt = /^\d+$/, + rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, + // If a string has leading or trailing space, + // contains a comma double quote or a newline + // it needs to be quoted in CSV output + rxNeedsQuoting = /^\s|\s$|,|"|\n/, + trim = (function () { + // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists + if (String.prototype.trim) { + return function (s) { + return s.trim(); + }; + } else { + return function (s) { + return s.replace(/^\s*/, '').replace(/\s*$/, ''); + }; + } + }()); - function chomp(s) { - if (s.charAt(s.length - 1) !== "\n") { - // Does not end with \n, just return string - return s; - } else { - // Remove the \n - return s.substring(0, s.length - 1); - } - } + function chomp(s) { + if (s.charAt(s.length - 1) !== "\n") { + // Does not end with \n, just return string + return s; + } else { + // Remove the \n + return s.substring(0, s.length - 1); + } + } }(jQuery, this.recline.Backend)); @@ -3110,7 +3399,7 @@ this.recline.Backend = this.recline.Backend || {}; if (!metadata.id) { metadata.id = String(Math.floor(Math.random() * 100000000) + 1); } - var backend = recline.Model.backends['memory']; + var backend = new recline.Backend.Memory(); var datasetInfo = { documents: data, metadata: metadata @@ -3125,7 +3414,7 @@ this.recline.Backend = this.recline.Backend || {}; } } backend.addDataset(datasetInfo); - var dataset = new recline.Model.Dataset({id: metadata.id}, 'memory'); + var dataset = new recline.Model.Dataset({id: metadata.id}, backend); dataset.fetch(); return dataset; }; @@ -3160,6 +3449,7 @@ this.recline.Backend = this.recline.Backend || {}; // etc ... //
    my.Memory = my.Base.extend({ + __type__: 'memory', initialize: function() { this.datasets = {}; }, @@ -3207,13 +3497,9 @@ this.recline.Backend = this.recline.Backend || {}; var out = {}; var numRows = queryObj.size; var start = queryObj.from; - results = this.datasets[model.id].documents; - _.each(queryObj.filters, function(filter) { - results = _.filter(results, function(doc) { - var fieldId = _.keys(filter.term)[0]; - return (doc[fieldId] == filter.term[fieldId]); - }); - }); + var results = this.datasets[model.id].documents; + results = this._applyFilters(results, queryObj); + results = this._applyFreeTextQuery(model, results, queryObj); // not complete sorting! _.each(queryObj.sort, function(sortObj) { var fieldName = _.keys(sortObj)[0]; @@ -3231,6 +3517,42 @@ this.recline.Backend = this.recline.Backend || {}; return dfd.promise(); }, + // in place filtering + _applyFilters: function(results, queryObj) { + _.each(queryObj.filters, function(filter) { + results = _.filter(results, function(doc) { + var fieldId = _.keys(filter.term)[0]; + return (doc[fieldId] == filter.term[fieldId]); + }); + }); + return results; + }, + + // we OR across fields but AND across terms in query string + _applyFreeTextQuery: function(dataset, results, queryObj) { + if (queryObj.q) { + var terms = queryObj.q.split(' '); + results = _.filter(results, function(rawdoc) { + var matches = true; + _.each(terms, function(term) { + var foundmatch = false; + dataset.fields.each(function(field) { + var value = rawdoc[field.id].toString(); + // TODO regexes? + foundmatch = foundmatch || (value === term); + // TODO: early out (once we are true should break to spare unnecessary testing) + // if (foundmatch) return true; + }); + matches = matches && foundmatch; + // TODO: early out (once false should break to spare unnecessary testing) + // if (!matches) return false; + }); + return matches; + }); + } + return results; + }, + _computeFacets: function(documents, queryObj) { var facetResults = {}; if (!queryObj.facets) { @@ -3267,6 +3589,5 @@ this.recline.Backend = this.recline.Backend || {}; return facetResults; } }); - recline.Model.backends['memory'] = new my.Memory(); }(jQuery, this.recline.Backend)); From 60b7f19f71daddd82ecfdaee021a6130810c0517 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 17 Apr 2012 12:53:11 +0100 Subject: [PATCH 10/20] [vendor,refactor][xs]: lay vendor directory in consistent way ({lib}/{version}/{js-file}). --- app/index.html | 8 ++++---- test/index.html | 11 ++++++----- .../{backbone-0.5.1.js => backbone/0.5.1/backbone.js} | 0 .../0.7/jquery.flot.js} | 0 vendor/{jquery-1.7.1.js => jquery/1.7.1/jquery.js} | 0 .../1.1.6/underscore.js} | 0 6 files changed, 10 insertions(+), 9 deletions(-) rename vendor/{backbone-0.5.1.js => backbone/0.5.1/backbone.js} (100%) rename vendor/{jquery.flot-0.7.js => jquery.flot/0.7/jquery.flot.js} (100%) rename vendor/{jquery-1.7.1.js => jquery/1.7.1/jquery.js} (100%) rename vendor/{underscore-1.1.6.js => underscore/1.1.6/underscore.js} (100%) diff --git a/app/index.html b/app/index.html index e6367736..63e1d9ef 100644 --- a/app/index.html +++ b/app/index.html @@ -29,11 +29,11 @@ - - - + + + - + diff --git a/test/index.html b/test/index.html index da0e90b8..8bc78f41 100644 --- a/test/index.html +++ b/test/index.html @@ -7,13 +7,14 @@ - - - + + + - - + + + diff --git a/vendor/backbone-0.5.1.js b/vendor/backbone/0.5.1/backbone.js similarity index 100% rename from vendor/backbone-0.5.1.js rename to vendor/backbone/0.5.1/backbone.js diff --git a/vendor/jquery.flot-0.7.js b/vendor/jquery.flot/0.7/jquery.flot.js similarity index 100% rename from vendor/jquery.flot-0.7.js rename to vendor/jquery.flot/0.7/jquery.flot.js diff --git a/vendor/jquery-1.7.1.js b/vendor/jquery/1.7.1/jquery.js similarity index 100% rename from vendor/jquery-1.7.1.js rename to vendor/jquery/1.7.1/jquery.js diff --git a/vendor/underscore-1.1.6.js b/vendor/underscore/1.1.6/underscore.js similarity index 100% rename from vendor/underscore-1.1.6.js rename to vendor/underscore/1.1.6/underscore.js From 92ec8d5b3ed04a6ba8c3c5a7ede268769a442806 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sat, 21 Apr 2012 22:48:33 +0100 Subject: [PATCH 11/20] [backend/memory,bugfix][xs]: fix error on memory filtering when field value is null (cannot call toString). --- src/backend/memory.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/memory.js b/src/backend/memory.js index f79991e8..adf06bab 100644 --- a/src/backend/memory.js +++ b/src/backend/memory.js @@ -158,7 +158,8 @@ this.recline.Backend = this.recline.Backend || {}; _.each(terms, function(term) { var foundmatch = false; dataset.fields.each(function(field) { - var value = rawdoc[field.id].toString(); + var value = rawdoc[field.id]; + if (value !== null) { value = value.toString(); } // TODO regexes? foundmatch = foundmatch || (value === term); // TODO: early out (once we are true should break to spare unnecessary testing) From 793fde461753bdaf5af660a46be1976b6662a7a3 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sat, 21 Apr 2012 23:47:52 +0100 Subject: [PATCH 12/20] [bugfix,view-map][s]: 30m to track down and fix a bug whereby map view was ignoring config passed to it. * setupGeometryFields was running (and overwriting) even if fields set in state passed in (which I don't think we want). --- src/view-map.js | 14 +++++++++----- src/view.js | 2 +- test/view.test.js | 11 ++++++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/view-map.js b/src/view-map.js index 9ceb07b1..a6361d75 100644 --- a/src/view-map.js +++ b/src/view-map.js @@ -335,12 +335,16 @@ my.Map = Backbone.View.extend({ // If not found, the user can define them via the UI form. _setupGeometryField: function(){ var geomField, latField, lonField; - this.state.set({ - geomField: this._checkField(this.geometryFieldNames), - latField: this._checkField(this.latitudeFieldNames), - lonField: this._checkField(this.longitudeFieldNames) - }); this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + // should not overwrite if we have already set this (e.g. explicitly via state) + if (!this.geomReady) { + this.state.set({ + geomField: this._checkField(this.geometryFieldNames), + latField: this._checkField(this.latitudeFieldNames), + lonField: this._checkField(this.longitudeFieldNames) + }); + this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + } }, // Private: Check if a field in the current model exists in the provided diff --git a/src/view.js b/src/view.js index 358b2aa3..44881967 100644 --- a/src/view.js +++ b/src/view.js @@ -184,8 +184,8 @@ my.DataExplorer = Backbone.View.extend({ initialize: function(options) { var self = this; this.el = $(this.el); - // Hash of 'page' views (i.e. those for whole page) keyed by page name this._setupState(options.state); + // Hash of 'page' views (i.e. those for whole page) keyed by page name if (options.views) { this.pageViews = options.views; } else { diff --git a/test/view.test.js b/test/view.test.js index 19fcc177..4dec5d89 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -47,17 +47,26 @@ test('initialize state', function () { currentView: 'graph', 'view-grid': { hiddenFields: ['x'] + }, + 'view-map': { + latField: 'lat1', + lonField: 'lon1' } } }); ok(explorer.state.get('readOnly')); ok(explorer.state.get('currentView'), 'graph'); + // check the correct view is visible var css = explorer.el.find('.navigation a[data-view="graph"]').attr('class').split(' '); ok(_.contains(css, 'disabled'), css); - var css = explorer.el.find('.navigation a[data-view="grid"]').attr('class').split(' '); ok(!(_.contains(css, 'disabled')), css); + + // check pass through of view config + deepEqual(explorer.state.get('view-grid')['hiddenFields'], ['x']); + equal(explorer.state.get('view-map')['lonField'], 'lon1'); + $el.remove(); }); From 24295e78a2800e1c910c3c85a03a10bcea3eda87 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sat, 21 Apr 2012 23:52:22 +0100 Subject: [PATCH 13/20] [view/map][s]: make map view much more robust regarding bad or missing data in location fields. --- src/view-map.js | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/view-map.js b/src/view-map.js index a6361d75..9553b9e3 100644 --- a/src/view-map.js +++ b/src/view-map.js @@ -256,9 +256,12 @@ my.Map = Backbone.View.extend({ if (!(docs instanceof Array)) docs = [docs]; + var count = 0; + var wrongSoFar = 0; _.every(docs,function(doc){ + count += 1; var feature = self._getGeometryFromDocument(doc); - if (typeof feature === 'undefined'){ + if (typeof feature === 'undefined' || feature === null){ // Empty field return true; } else if (feature instanceof Object){ @@ -275,16 +278,20 @@ my.Map = Backbone.View.extend({ feature.properties.cid = doc.cid; try { - self.features.addGeoJSON(feature); + self.features.addGeoJSON(feature); } catch (except) { - var msg = 'Wrong geometry value'; - if (except.message) msg += ' (' + except.message + ')'; + wrongSoFar += 1; + var msg = 'Wrong geometry value'; + if (except.message) msg += ' (' + except.message + ')'; + if (wrongSoFar <= 10) { my.notify(msg,{category:'error'}); - return false; + } } } else { - my.notify('Wrong geometry value',{category:'error'}); - return false; + wrongSoFar += 1 + if (wrongSoFar <= 10) { + my.notify('Wrong geometry value',{category:'error'}); + } } return true; }); @@ -317,13 +324,17 @@ my.Map = Backbone.View.extend({ return doc.attributes[this.state.get('geomField')]; } else if (this.state.get('lonField') && this.state.get('latField')){ // We'll create a GeoJSON like point object from the two lat/lon fields - return { - type: 'Point', - coordinates: [ - doc.attributes[this.state.get('lonField')], - doc.attributes[this.state.get('latField')] - ] - }; + var lon = doc.get(this.state.get('lonField')); + var lat = doc.get(this.state.get('latField')); + if (lon && lat) { + return { + type: 'Point', + coordinates: [ + doc.attributes[this.state.get('lonField')], + doc.attributes[this.state.get('latField')] + ] + }; + } } return null; } From 67ff75772282f761f4b82587f2a045f44a4119b1 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sat, 21 Apr 2012 23:54:49 +0100 Subject: [PATCH 14/20] [#68,field][s]: link and markdown format for strings with default formatter. --- src/model.js | 19 ++++++++++++++++++- test/model.test.js | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/model.js b/src/model.js index 4145507b..88c724a7 100644 --- a/src/model.js +++ b/src/model.js @@ -217,7 +217,8 @@ my.DocumentList = Backbone.Collection.extend({ // * format: (optional) used to indicate how the data should be formatted. For example: // * type=date, format=yyyy-mm-dd // * type=float, format=percentage -// * type=float, format='###,###.##' +// * type=string, format=link (render as hyperlink) +// * type=string, format=markdown (render as markdown if Showdown available) // * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below). // // Following additional instance properties: @@ -273,6 +274,22 @@ my.Field = Backbone.Model.extend({ if (format === 'percentage') { return val + '%'; } + return val; + }, + 'string': function(val, field, doc) { + var format = field.get('format'); + if (format === 'link') { + return 'VAL'.replace(/VAL/g, val); + } else if (format === 'markdown') { + if (typeof Showdown !== 'undefined') { + var showdown = new Showdown.converter(); + out = showdown.makeHtml(val); + return out; + } else { + return val; + } + } + return val; } } }); diff --git a/test/model.test.js b/test/model.test.js index 2f0c4e6a..f4a4c623 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -39,7 +39,12 @@ test('Field: basics', function () { }); test('Field: default renderers', function () { - var doc = new recline.Model.Document({x: 12.3, myobject: {a: 1, b: 2}}); + var doc = new recline.Model.Document({ + x: 12.3, + myobject: {a: 1, b: 2}, + link: 'http://abc.com/', + markdown: '### ABC' + }); var field = new recline.Model.Field({id: 'myobject', type: 'object'}); var out = doc.getFieldValue(field); var exp = '{"a":1,"b":2}'; @@ -49,6 +54,17 @@ test('Field: default renderers', function () { var out = doc.getFieldValue(field); var exp = '12.3%'; equal(out, exp); + + var field = new recline.Model.Field({id: 'link', type: 'string', format: 'link'}); + var out = doc.getFieldValue(field); + var exp = 'http://abc.com/'; + equal(out, exp); + + var field = new recline.Model.Field({id: 'markdown', type: 'string', format: 'markdown'}); + var out = doc.getFieldValue(field); + // Showdown is not installed so nothing should happen + var exp = doc.get('markdown'); + equal(out, exp); }); test('Field: custom deriver and renderer', function () { From 31d829f53f0a84f2462cad27cf36e58e9466c3ae Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 22 Apr 2012 00:03:59 +0100 Subject: [PATCH 15/20] [#17,backend][s]: add readonly attribute on backends indicating whether they are 'read-only'. --- src/backend/base.js | 7 +++++++ src/backend/dataproxy.js | 1 + src/backend/elasticsearch.js | 1 + src/backend/gdocs.js | 1 + src/backend/memory.js | 1 + test/backend.test.js | 7 +++++++ 6 files changed, 18 insertions(+) diff --git a/src/backend/base.js b/src/backend/base.js index e9c4eea7..1b06dc01 100644 --- a/src/backend/base.js +++ b/src/backend/base.js @@ -32,6 +32,13 @@ this.recline.Backend = this.recline.Backend || {}; // backends (see recline.Model.Dataset.initialize). __type__: 'base', + + // ### readonly + // + // Class level attribute indicating that this backend is read-only (that + // is, cannot be written to). + readonly: true, + // ### sync // // An implementation of Backbone.sync that will be used to override diff --git a/src/backend/dataproxy.js b/src/backend/dataproxy.js index 8f2b7496..16db3db6 100644 --- a/src/backend/dataproxy.js +++ b/src/backend/dataproxy.js @@ -18,6 +18,7 @@ this.recline.Backend = this.recline.Backend || {}; // Note that this is a **read-only** backend. my.DataProxy = my.Base.extend({ __type__: 'dataproxy', + readonly: true, defaults: { dataproxy_url: 'http://jsonpdataproxy.appspot.com' }, diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js index 164393a8..0ccb5295 100644 --- a/src/backend/elasticsearch.js +++ b/src/backend/elasticsearch.js @@ -21,6 +21,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ __type__: 'elasticsearch', + readonly: true, _getESUrl: function(dataset) { var out = dataset.get('elasticsearch_url'); if (out) return out; diff --git a/src/backend/gdocs.js b/src/backend/gdocs.js index e6f29b55..c9b5b551 100644 --- a/src/backend/gdocs.js +++ b/src/backend/gdocs.js @@ -18,6 +18,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    my.GDoc = my.Base.extend({ __type__: 'gdoc', + readonly: true, getUrl: function(dataset) { var url = dataset.get('url'); if (url.indexOf('feeds/list') != -1) { diff --git a/src/backend/memory.js b/src/backend/memory.js index adf06bab..e013aa19 100644 --- a/src/backend/memory.js +++ b/src/backend/memory.js @@ -71,6 +71,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    my.Memory = my.Base.extend({ __type__: 'memory', + readonly: false, initialize: function() { this.datasets = {}; }, diff --git a/test/backend.test.js b/test/backend.test.js index f7d96f60..69686b8a 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -25,6 +25,11 @@ function makeBackendDataset() { return dataset; } +test('Memory Backend: readonly', function () { + var backend = new recline.Backend.Memory(); + equal(backend.readonly, false); +}); + test('Memory Backend: createDataset', function () { var dataset = recline.Backend.createDataset(memoryData.documents, memoryData.fields, memoryData.metadata); equal(memoryData.metadata.id, dataset.id); @@ -217,6 +222,8 @@ test('DataProxy Backend', function() { // needed only if not stubbing // stop(); var backend = new recline.Backend.DataProxy(); + ok(backend.readonly, false); + var dataset = new recline.Model.Dataset({ url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv' }, From 1614cf318c5497e6601b18e9e8f4eb056c8b7c6e Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 22 Apr 2012 17:32:59 +0100 Subject: [PATCH 16/20] [CNAME][xs]: add CNAME (reclinejs.com). --- CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 CNAME diff --git a/CNAME b/CNAME new file mode 100644 index 00000000..a14a1404 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +reclinejs.com From d19337eda4cbb90b01eec419990e29146e11695b Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 22 Apr 2012 18:23:38 +0100 Subject: [PATCH 17/20] [build][xs]: regular build of recline.js. --- recline.js | 88 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/recline.js b/recline.js index e404d78b..a0efe02e 100644 --- a/recline.js +++ b/recline.js @@ -285,7 +285,8 @@ my.DocumentList = Backbone.Collection.extend({ // * format: (optional) used to indicate how the data should be formatted. For example: // * type=date, format=yyyy-mm-dd // * type=float, format=percentage -// * type=float, format='###,###.##' +// * type=string, format=link (render as hyperlink) +// * type=string, format=markdown (render as markdown if Showdown available) // * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below). // // Following additional instance properties: @@ -341,6 +342,22 @@ my.Field = Backbone.Model.extend({ if (format === 'percentage') { return val + '%'; } + return val; + }, + 'string': function(val, field, doc) { + var format = field.get('format'); + if (format === 'link') { + return 'VAL'.replace(/VAL/g, val); + } else if (format === 'markdown') { + if (typeof Showdown !== 'undefined') { + var showdown = new Showdown.converter(); + out = showdown.makeHtml(val); + return out; + } else { + return val; + } + } + return val; } } }); @@ -1626,9 +1643,12 @@ my.Map = Backbone.View.extend({ if (!(docs instanceof Array)) docs = [docs]; + var count = 0; + var wrongSoFar = 0; _.every(docs,function(doc){ + count += 1; var feature = self._getGeometryFromDocument(doc); - if (typeof feature === 'undefined'){ + if (typeof feature === 'undefined' || feature === null){ // Empty field return true; } else if (feature instanceof Object){ @@ -1645,16 +1665,20 @@ my.Map = Backbone.View.extend({ feature.properties.cid = doc.cid; try { - self.features.addGeoJSON(feature); + self.features.addGeoJSON(feature); } catch (except) { - var msg = 'Wrong geometry value'; - if (except.message) msg += ' (' + except.message + ')'; + wrongSoFar += 1; + var msg = 'Wrong geometry value'; + if (except.message) msg += ' (' + except.message + ')'; + if (wrongSoFar <= 10) { my.notify(msg,{category:'error'}); - return false; + } } } else { - my.notify('Wrong geometry value',{category:'error'}); - return false; + wrongSoFar += 1 + if (wrongSoFar <= 10) { + my.notify('Wrong geometry value',{category:'error'}); + } } return true; }); @@ -1687,13 +1711,17 @@ my.Map = Backbone.View.extend({ return doc.attributes[this.state.get('geomField')]; } else if (this.state.get('lonField') && this.state.get('latField')){ // We'll create a GeoJSON like point object from the two lat/lon fields - return { - type: 'Point', - coordinates: [ - doc.attributes[this.state.get('lonField')], - doc.attributes[this.state.get('latField')] - ] - }; + var lon = doc.get(this.state.get('lonField')); + var lat = doc.get(this.state.get('latField')); + if (lon && lat) { + return { + type: 'Point', + coordinates: [ + doc.attributes[this.state.get('lonField')], + doc.attributes[this.state.get('latField')] + ] + }; + } } return null; } @@ -1705,12 +1733,16 @@ my.Map = Backbone.View.extend({ // If not found, the user can define them via the UI form. _setupGeometryField: function(){ var geomField, latField, lonField; - this.state.set({ - geomField: this._checkField(this.geometryFieldNames), - latField: this._checkField(this.latitudeFieldNames), - lonField: this._checkField(this.longitudeFieldNames) - }); this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + // should not overwrite if we have already set this (e.g. explicitly via state) + if (!this.geomReady) { + this.state.set({ + geomField: this._checkField(this.geometryFieldNames), + latField: this._checkField(this.latitudeFieldNames), + lonField: this._checkField(this.longitudeFieldNames) + }); + this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + } }, // Private: Check if a field in the current model exists in the provided @@ -2172,8 +2204,8 @@ my.DataExplorer = Backbone.View.extend({ initialize: function(options) { var self = this; this.el = $(this.el); - // Hash of 'page' views (i.e. those for whole page) keyed by page name this._setupState(options.state); + // Hash of 'page' views (i.e. those for whole page) keyed by page name if (options.views) { this.pageViews = options.views; } else { @@ -2772,6 +2804,13 @@ this.recline.Backend = this.recline.Backend || {}; // backends (see recline.Model.Dataset.initialize). __type__: 'base', + + // ### readonly + // + // Class level attribute indicating that this backend is read-only (that + // is, cannot be written to). + readonly: true, + // ### sync // // An implementation of Backbone.sync that will be used to override @@ -2891,6 +2930,7 @@ this.recline.Backend = this.recline.Backend || {}; // Note that this is a **read-only** backend. my.DataProxy = my.Base.extend({ __type__: 'dataproxy', + readonly: true, defaults: { dataproxy_url: 'http://jsonpdataproxy.appspot.com' }, @@ -2970,6 +3010,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ __type__: 'elasticsearch', + readonly: true, _getESUrl: function(dataset) { var out = dataset.get('elasticsearch_url'); if (out) return out; @@ -3088,6 +3129,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    my.GDoc = my.Base.extend({ __type__: 'gdoc', + readonly: true, getUrl: function(dataset) { var url = dataset.get('url'); if (url.indexOf('feeds/list') != -1) { @@ -3450,6 +3492,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    my.Memory = my.Base.extend({ __type__: 'memory', + readonly: false, initialize: function() { this.datasets = {}; }, @@ -3537,7 +3580,8 @@ this.recline.Backend = this.recline.Backend || {}; _.each(terms, function(term) { var foundmatch = false; dataset.fields.each(function(field) { - var value = rawdoc[field.id].toString(); + var value = rawdoc[field.id]; + if (value !== null) { value = value.toString(); } // TODO regexes? foundmatch = foundmatch || (value === term); // TODO: early out (once we are true should break to spare unnecessary testing) From 1bf64c5f9475c73ec1d5306731eeb54ae3c0fc5f Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 22 Apr 2012 22:17:47 +0100 Subject: [PATCH 18/20] [#61,backend/elasticsearch][m]: create, update and delete support in elasticsearch backend -- fixes #61. --- src/backend/elasticsearch.js | 33 ++++++++++++++-- test/backend.elasticsearch.test.js | 62 +++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js index 0ccb5295..03448277 100644 --- a/src/backend/elasticsearch.js +++ b/src/backend/elasticsearch.js @@ -21,7 +21,7 @@ this.recline.Backend = this.recline.Backend || {}; //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ __type__: 'elasticsearch', - readonly: true, + readonly: false, _getESUrl: function(dataset) { var out = dataset.get('elasticsearch_url'); if (out) return out; @@ -55,9 +55,36 @@ this.recline.Backend = this.recline.Backend || {}; dfd.reject(arguments); }); return dfd.promise(); + } else if (model.__type__ == 'Document') { + var base = this._getESUrl(model.dataset) + '/' + model.id; + return $.ajax({ + url: base, + dataType: 'json' + }); + } + } else if (method === 'update') { + if (model.__type__ == 'Document') { + var data = JSON.stringify(model.toJSON()); + var base = this._getESUrl(model.dataset); + if (model.id) { + base += '/' + model.id; + } + return $.ajax({ + url: base, + type: 'POST', + data: data, + dataType: 'json' + }); + } + } else if (method === 'delete') { + if (model.__type__ == 'Document') { + var base = this._getESUrl(model.dataset) + '/' + model.id; + return $.ajax({ + url: base, + type: 'DELETE', + dataType: 'json' + }); } - } else { - alert('This backend currently only supports read operations'); } }, _normalizeQuery: function(queryObj) { diff --git a/test/backend.elasticsearch.test.js b/test/backend.elasticsearch.test.js index 0a132d0c..9a0206fc 100644 --- a/test/backend.elasticsearch.test.js +++ b/test/backend.elasticsearch.test.js @@ -107,7 +107,7 @@ var sample_data = { "took": 2 }; -test("ElasticSearch", function() { +test("ElasticSearch query", function() { var backend = new recline.Backend.ElasticSearch(); var dataset = new recline.Model.Dataset({ url: 'https://localhost:9200/my-es-db/my-es-type' @@ -149,4 +149,64 @@ test("ElasticSearch", function() { $.ajax.restore(); }); +test("ElasticSearch write", function() { + var backend = new recline.Backend.ElasticSearch(); + var dataset = new recline.Model.Dataset({ + url: 'http://localhost:9200/recline-test/es-write' + }, + backend + ); + + stop(); + + var id = parseInt(Math.random()*100000000).toString(); + var doc = new recline.Model.Document({ + id: id, + title: 'my title' + }); + doc.backend = backend; + doc.dataset = dataset; + dataset.currentDocuments.add(doc); + var jqxhr = doc.save(); + jqxhr.done(function(data) { + ok(data.ok); + equal(data._id, id); + equal(data._type, 'es-write'); + equal(data._version, 1); + + // update + doc.set({title: 'new title'}); + var jqxhr = doc.save(); + jqxhr.done(function(data) { + equal(data._version, 2); + + // delete + var jqxhr = doc.destroy(); + jqxhr.done(function(data) { + ok(data.ok); + doc = null; + + // try to get ... + var olddoc = new recline.Model.Document({id: id}); + equal(olddoc.get('title'), null); + olddoc.dataset = dataset; + olddoc.backend = backend; + var jqxhr = olddoc.fetch(); + jqxhr.done(function(data) { + // should not be here + ok(false, 'Should have got 404'); + }).error(function(error) { + equal(error.status, 404); + equal(typeof olddoc.get('title'), 'undefined'); + start(); + }); + }); + }); + }).fail(function(error) { + console.log(error); + ok(false, 'Basic request failed - is ElasticSearch running locally on port 9200 (required for this test!)'); + start(); + }); +}); + })(this.jQuery); From a577866932a6b1ae0c482d2f4c67b3155b96af20 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 23 Apr 2012 02:30:16 +0100 Subject: [PATCH 19/20] [#61,backend/elasticearch][s]: refactor plus support for setting request headers (e.g. Authorization) plus can set backend url on initialization. * support for headers went into new _makeRequest method on backend base class * support for setting backend url on initialization (rather than depending on it being on Dataset/Document objects) * move upsert and delete methods out into distinct methods from being inside backbone sync The two last of these pave the way for use of ElasticSearch backend standalone (both independent from Recline and independent of Backbone) --- src/backend/base.js | 26 ++++++++ src/backend/elasticsearch.js | 115 ++++++++++++++++++++++++----------- 2 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/backend/base.js b/src/backend/base.js index 1b06dc01..94cccf4f 100644 --- a/src/backend/base.js +++ b/src/backend/base.js @@ -99,6 +99,32 @@ this.recline.Backend = this.recline.Backend || {}; query: function(model, queryObj) { }, + // ### _makeRequest + // + // Just $.ajax but in any headers in the 'headers' attribute of this + // Backend instance. Example: + // + //
    +    // var jqxhr = this._makeRequest({
    +    //   url: the-url
    +    // });
    +    // 
    + _makeRequest: function(data) { + var headers = this.get('headers'); + var extras = {}; + if (headers) { + extras = { + beforeSend: function(req) { + _.each(headers, function(value, key) { + req.setRequestHeader(key, value); + }); + } + }; + } + var data = _.extend(extras, data); + return $.ajax(data); + }, + // convenience method to convert simple set of documents / rows to a QueryResult _docsToQueryResult: function(rows) { var hits = _.map(rows, function(row) { diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js index 03448277..546c0c8a 100644 --- a/src/backend/elasticsearch.js +++ b/src/backend/elasticsearch.js @@ -6,37 +6,39 @@ this.recline.Backend = this.recline.Backend || {}; // // Connecting to [ElasticSearch](http://www.elasticsearch.org/). // - // To use this backend ensure your Dataset has one of the following - // attributes (first one found is used): + // Usage: + // + //
    +  // var backend = new recline.Backend.ElasticSearch({
    +  //   // optional as can also be provided by Dataset/Document
    +  //   url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
    +  //   // optional
    +  //   headers: {dict of headers to add to each request}
    +  // });
    +  //
    +  // @param {String} url: url for ElasticSearch type/table, e.g. for ES running
    +  // on localhost:9200 with index // twitter and type tweet it would be:
    +  // 
    +  // 
    http://localhost:9200/twitter/tweet
    + // + // This url is optional since the ES endpoint url may be specified on the the + // dataset (and on a Document by the document having a dataset attribute) by + // having one of the following (see also `_getESUrl` function): // //
       // elasticsearch_url
       // webstore_url
       // url
       // 
    - // - // This should point to the ES type url. E.G. for ES running on - // localhost:9200 with index twitter and type tweet it would be - // - //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ __type__: 'elasticsearch', readonly: false, - _getESUrl: function(dataset) { - var out = dataset.get('elasticsearch_url'); - if (out) return out; - out = dataset.get('webstore_url'); - if (out) return out; - out = dataset.get('url'); - return out; - }, sync: function(method, model, options) { var self = this; if (method === "read") { if (model.__type__ == 'Dataset') { - var base = self._getESUrl(model); - var schemaUrl = base + '/_mapping'; - var jqxhr = $.ajax({ + var schemaUrl = self._getESUrl(model) + '/_mapping'; + var jqxhr = this._makeRequest({ url: schemaUrl, dataType: 'jsonp' }); @@ -57,36 +59,75 @@ this.recline.Backend = this.recline.Backend || {}; return dfd.promise(); } else if (model.__type__ == 'Document') { var base = this._getESUrl(model.dataset) + '/' + model.id; - return $.ajax({ + return this._makeRequest({ url: base, dataType: 'json' }); } } else if (method === 'update') { if (model.__type__ == 'Document') { - var data = JSON.stringify(model.toJSON()); - var base = this._getESUrl(model.dataset); - if (model.id) { - base += '/' + model.id; - } - return $.ajax({ - url: base, - type: 'POST', - data: data, - dataType: 'json' - }); + return this.upsert(model.toJSON(), this._getESUrl(model.dataset)); } } else if (method === 'delete') { if (model.__type__ == 'Document') { - var base = this._getESUrl(model.dataset) + '/' + model.id; - return $.ajax({ - url: base, - type: 'DELETE', - dataType: 'json' - }); + var url = this._getESUrl(model.dataset); + return this.delete(model.id, url); } } }, + + // ### upsert + // + // create / update a document to ElasticSearch backend + // + // @param {Object} doc an object to insert to the index. + // @param {string} url (optional) url for ElasticSearch endpoint (if not + // defined called this._getESUrl() + upsert: function(doc, url) { + var data = JSON.stringify(doc); + url = url ? url : this._getESUrl(); + if (doc.id) { + url += '/' + doc.id; + } + return this._makeRequest({ + url: url, + type: 'POST', + data: data, + dataType: 'json' + }); + }, + + // ### delete + // + // Delete a document from the ElasticSearch backend. + // + // @param {Object} id id of object to delete + // @param {string} url (optional) url for ElasticSearch endpoint (if not + // provided called this._getESUrl() + delete: function(id, url) { + url = url ? url : this._getESUrl(); + url += '/' + id; + return this._makeRequest({ + url: url, + type: 'DELETE', + dataType: 'json' + }); + }, + + // ### _getESUrl + // + // get url to ElasticSearch endpoint (see above) + _getESUrl: function(dataset) { + if (dataset) { + var out = dataset.get('elasticsearch_url'); + if (out) return out; + out = dataset.get('webstore_url'); + if (out) return out; + out = dataset.get('url'); + return out; + } + return this.get('url'); + }, _normalizeQuery: function(queryObj) { var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj); if (out.q !== undefined && out.q.trim() === '') { @@ -123,7 +164,7 @@ this.recline.Backend = this.recline.Backend || {}; var queryNormalized = this._normalizeQuery(queryObj); var data = {source: JSON.stringify(queryNormalized)}; var base = this._getESUrl(model); - var jqxhr = $.ajax({ + var jqxhr = this._makeRequest({ url: base + '/_search', data: data, dataType: 'jsonp' From e5316e03cf1e1f72bbcf2c200149cc0e41f2b5c6 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 23 Apr 2012 02:36:11 +0100 Subject: [PATCH 20/20] [build][s]: regular build of docs and library. --- docs/backend/base.html | 34 +++++++- docs/backend/dataproxy.html | 1 + docs/backend/elasticsearch.html | 108 +++++++++++++++++++------ docs/backend/gdocs.html | 1 + docs/backend/memory.html | 4 +- docs/model.html | 19 ++++- docs/view-map.html | 59 ++++++++------ docs/view.html | 4 +- recline.js | 138 +++++++++++++++++++++++++++----- 9 files changed, 291 insertions(+), 77 deletions(-) diff --git a/docs/backend/base.html b/docs/backend/base.html index 9f022974..38e13e3a 100644 --- a/docs/backend/base.html +++ b/docs/backend/base.html @@ -22,7 +22,10 @@ object as a string (e.g. recline.Backend.Memory) or for Backends within recline.Backend module it may be their class name.

    This value is used as an identifier for this backend when initializing -backends (see recline.Model.Dataset.initialize).

        __type__: 'base',

    sync

    +backends (see recline.Model.Dataset.initialize).

        __type__: 'base',

    readonly

    + +

    Class level attribute indicating that this backend is read-only (that +is, cannot be written to).

        readonly: true,

    sync

    An implementation of Backbone.sync that will be used to override Backbone.sync on operations for Datasets and Documents which are using this backend.

    @@ -36,7 +39,7 @@ for Documents because they are loaded in bulk by the query method.

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

        sync: function(method, model, options) {
         },
    -    

    query

    +

    query

    Query the backend for documents returning them in bulk. This method will be used by the Dataset.query method to search the backend for documents, @@ -77,7 +80,30 @@ details):

    } }
        query: function(model, queryObj) {
    -    },

    convenience method to convert simple set of documents / rows to a QueryResult

        _docsToQueryResult: function(rows) {
    +    },

    _makeRequest

    + +

    Just $.ajax but in any headers in the 'headers' attribute of this +Backend instance. Example:

    + +
    +var jqxhr = this._makeRequest({
    +  url: the-url
    +});
    +
        _makeRequest: function(data) {
    +      var headers = this.get('headers');
    +      var extras = {};
    +      if (headers) {
    +        extras = {
    +          beforeSend: function(req) {
    +            _.each(headers, function(value, key) {
    +              req.setRequestHeader(key, value);
    +            });
    +          }
    +        };
    +      }
    +      var data = _.extend(extras, data);
    +      return $.ajax(data);
    +    },

    convenience method to convert simple set of documents / rows to a QueryResult

        _docsToQueryResult: function(rows) {
           var hits = _.map(rows, function(row) {
             return { _source: row };
           });
    @@ -85,7 +111,7 @@ details):

    total: null, hits: hits }; - },

    _wrapInTimeout

    + },

    _wrapInTimeout

    Convenience method providing a crude way to catch backend errors on JSONP calls. Many of backends use JSONP and so will not get error messages and this is diff --git a/docs/backend/dataproxy.html b/docs/backend/dataproxy.html index b65ef570..0ff29c43 100644 --- a/docs/backend/dataproxy.html +++ b/docs/backend/dataproxy.html @@ -20,6 +20,7 @@

    Note that this is a read-only backend.

      my.DataProxy = my.Base.extend({
         __type__: 'dataproxy',
    +    readonly: true,
         defaults: {
           dataproxy_url: 'http://jsonpdataproxy.appspot.com'
         },
    diff --git a/docs/backend/elasticsearch.html b/docs/backend/elasticsearch.html
    index 0257f24c..11bb4b3a 100644
    --- a/docs/backend/elasticsearch.html
    +++ b/docs/backend/elasticsearch.html
    @@ -5,35 +5,38 @@
     
     

    Connecting to ElasticSearch.

    -

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

    +

    Usage:

    + +
    +var backend = new recline.Backend.ElasticSearch({
    +  // optional as can also be provided by Dataset/Document
    +  url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
    +  // optional
    +  headers: {dict of headers to add to each request}
    +});
    +
    +@param {String} url: url for ElasticSearch type/table, e.g. for ES running
    +on localhost:9200 with index // twitter and type tweet it would be:
    +
    +
    http://localhost:9200/twitter/tweet
    + +This url is optional since the ES endpoint url may be specified on the the +dataset (and on a Document by the document having a dataset attribute) by +having one of the following (see also `_getESUrl` function):
     elasticsearch_url
     webstore_url
     url
    -
    - -

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

    - -
    http://localhost:9200/twitter/tweet
      my.ElasticSearch = my.Base.extend({
    +
      my.ElasticSearch = my.Base.extend({
         __type__: 'elasticsearch',
    -    _getESUrl: function(dataset) {
    -      var out = dataset.get('elasticsearch_url');
    -      if (out) return out;
    -      out = dataset.get('webstore_url');
    -      if (out) return out;
    -      out = dataset.get('url');
    -      return out;
    -    },
    +    readonly: false,
         sync: function(method, model, options) {
           var self = this;
           if (method === "read") {
             if (model.__type__ == 'Dataset') {
    -          var base = self._getESUrl(model);
    -          var schemaUrl = base + '/_mapping';
    -          var jqxhr = $.ajax({
    +          var schemaUrl = self._getESUrl(model) + '/_mapping';
    +          var jqxhr = this._makeRequest({
                 url: schemaUrl,
                 dataType: 'jsonp'
               });
    @@ -50,10 +53,67 @@ localhost:9200 with index twitter and type tweet it would be

    dfd.reject(arguments); }); return dfd.promise(); + } else if (model.__type__ == 'Document') { + var base = this._getESUrl(model.dataset) + '/' + model.id; + return this._makeRequest({ + url: base, + dataType: 'json' + }); + } + } else if (method === 'update') { + if (model.__type__ == 'Document') { + return this.upsert(model.toJSON(), this._getESUrl(model.dataset)); + } + } else if (method === 'delete') { + if (model.__type__ == 'Document') { + var url = this._getESUrl(model.dataset); + return this.delete(model.id, url); } - } else { - alert('This backend currently only supports read operations'); } + },

    upsert

    + +

    create / update a document to ElasticSearch backend

    + +

    @param {Object} doc an object to insert to the index. +@param {string} url (optional) url for ElasticSearch endpoint (if not +defined called this._getESUrl()

        upsert: function(doc, url) {
    +      var data = JSON.stringify(doc);
    +      url = url ? url : this._getESUrl();
    +      if (doc.id) {
    +        url += '/' + doc.id;
    +      }
    +      return this._makeRequest({
    +        url: url,
    +        type: 'POST',
    +        data: data,
    +        dataType: 'json'
    +      });
    +    },

    delete

    + +

    Delete a document from the ElasticSearch backend.

    + +

    @param {Object} id id of object to delete +@param {string} url (optional) url for ElasticSearch endpoint (if not +provided called this._getESUrl()

        delete: function(id, url) {
    +      url = url ? url : this._getESUrl();
    +      url += '/' + id;
    +      return this._makeRequest({
    +        url: url,
    +        type: 'DELETE',
    +        dataType: 'json'
    +      });
    +    },

    _getESUrl

    + +

    get url to ElasticSearch endpoint (see above)

        _getESUrl: function(dataset) {
    +      if (dataset) {
    +        var out = dataset.get('elasticsearch_url');
    +        if (out) return out;
    +        out = dataset.get('webstore_url');
    +        if (out) return out;
    +        out = dataset.get('url');
    +        return out;
    +      }
    +      return this.get('url');
         },
         _normalizeQuery: function(queryObj) {
           var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
    @@ -71,7 +131,7 @@ localhost:9200 with index twitter and type tweet it would be

    } }; delete out.q; - }

    now do filters (note the plural)

          if (out.filters && out.filters.length) {
    +      }

    now do filters (note the plural)

          if (out.filters && out.filters.length) {
             if (!out.filter) {
               out.filter = {};
             }
    @@ -89,12 +149,12 @@ localhost:9200 with index twitter and type tweet it would be

    var queryNormalized = this._normalizeQuery(queryObj); var data = {source: JSON.stringify(queryNormalized)}; var base = this._getESUrl(model); - var jqxhr = $.ajax({ + var jqxhr = this._makeRequest({ url: base + '/_search', data: data, dataType: 'jsonp' }); - var dfd = $.Deferred();

    TODO: fail case

          jqxhr.done(function(results) {
    +      var dfd = $.Deferred();

    TODO: fail case

          jqxhr.done(function(results) {
             _.each(results.hits.hits, function(hit) {
               if (!('id' in hit._source) && hit._id) {
                 hit._source.id = hit._id;
    diff --git a/docs/backend/gdocs.html b/docs/backend/gdocs.html
    index 4d23ae12..b84397fa 100644
    --- a/docs/backend/gdocs.html
    +++ b/docs/backend/gdocs.html
    @@ -16,6 +16,7 @@ var dataset = new recline.Model.Dataset({
     );
     
      my.GDoc = my.Base.extend({
         __type__: 'gdoc',
    +    readonly: true,
         getUrl: function(dataset) {
           var url = dataset.get('url');
           if (url.indexOf('feeds/list') != -1) {
    diff --git a/docs/backend/memory.html b/docs/backend/memory.html
    index b55f36f9..6304cee6 100644
    --- a/docs/backend/memory.html
    +++ b/docs/backend/memory.html
    @@ -67,6 +67,7 @@ If not defined (or id not provided) id will be autogenerated.

      my.Memory = my.Base.extend({
         __type__: 'memory',
    +    readonly: false,
         initialize: function() {
           this.datasets = {};
         },
    @@ -146,7 +147,8 @@ If not defined (or id not provided) id will be autogenerated.

    _.each(terms, function(term) { var foundmatch = false; dataset.fields.each(function(field) { - var value = rawdoc[field.id].toString();

    TODO regexes?

                  foundmatch = foundmatch || (value === term);

    TODO: early out (once we are true should break to spare unnecessary testing) + var value = rawdoc[field.id]; + if (value !== null) { value = value.toString(); }

    TODO regexes?

                  foundmatch = foundmatch || (value === term);

    TODO: early out (once we are true should break to spare unnecessary testing) if (foundmatch) return true;

                });
                 matches = matches && foundmatch;

    TODO: early out (once false should break to spare unnecessary testing) if (!matches) return false;

              });
    diff --git a/docs/model.html b/docs/model.html
    index c43556ca..3009088a 100644
    --- a/docs/model.html
    +++ b/docs/model.html
    @@ -183,7 +183,8 @@ for this document.

  • format: (optional) used to indicate how the data should be formatted. For example:
    • type=date, format=yyyy-mm-dd
    • type=float, format=percentage
    • -
    • type=float, format='###,###.##'
  • +
  • type=string, format=link (render as hyperlink)
  • +
  • type=string, format=markdown (render as markdown if Showdown available)
  • is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
  • @@ -233,6 +234,22 @@ value of this field prior to rendering.

    if (format === 'percentage') { return val + '%'; } + return val; + }, + 'string': function(val, field, doc) { + var format = field.get('format'); + if (format === 'link') { + return '<a href="VAL">VAL</a>'.replace(/VAL/g, val); + } else if (format === 'markdown') { + if (typeof Showdown !== 'undefined') { + var showdown = new Showdown.converter(); + out = showdown.makeHtml(val); + return out; + } else { + return val; + } + } + return val; } } }); diff --git a/docs/view-map.html b/docs/view-map.html index b865f6e7..73faab44 100644 --- a/docs/view-map.html +++ b/docs/view-map.html @@ -217,9 +217,12 @@ stopped and an error notification shown.

    if (!(docs instanceof Array)) docs = [docs]; + var count = 0; + var wrongSoFar = 0; _.every(docs,function(doc){ + count += 1; var feature = self._getGeometryFromDocument(doc); - if (typeof feature === 'undefined'){

    Empty field

            return true;
    +      if (typeof feature === 'undefined' || feature === null){

    Empty field

            return true;
           } else if (feature instanceof Object){

    Build popup contents TODO: mustache?

            html = ''
             for (key in doc.attributes){
    @@ -229,16 +232,20 @@ TODO: mustache?

    link this Leaflet layer to a Recline doc

            feature.properties.cid = doc.cid;
     
             try {
    -            self.features.addGeoJSON(feature);
    +          self.features.addGeoJSON(feature);
             } catch (except) {
    -            var msg = 'Wrong geometry value';
    -            if (except.message) msg += ' (' + except.message + ')';
    +          wrongSoFar += 1;
    +          var msg = 'Wrong geometry value';
    +          if (except.message) msg += ' (' + except.message + ')';
    +          if (wrongSoFar <= 10) {
                 my.notify(msg,{category:'error'});
    -            return false;
    +          }
             }
           } else {
    -        my.notify('Wrong geometry value',{category:'error'});
    -        return false;
    +        wrongSoFar += 1
    +        if (wrongSoFar <= 10) {
    +          my.notify('Wrong geometry value',{category:'error'});
    +        }
           }
           return true;
         });
    @@ -259,13 +266,17 @@ link this Leaflet layer to a Recline doc

    },

    Private: Return a GeoJSON geomtry extracted from the document fields

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

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

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

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

            return {
    -          type: 'Point',
    -          coordinates: [
    -            doc.attributes[this.state.get('lonField')],
    -            doc.attributes[this.state.get('latField')]
    -            ]
    -        };
    +      } else if (this.state.get('lonField') && this.state.get('latField')){

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

            var lon = doc.get(this.state.get('lonField'));
    +        var lat = doc.get(this.state.get('latField'));
    +        if (lon && lat) {
    +          return {
    +            type: 'Point',
    +            coordinates: [
    +              doc.attributes[this.state.get('lonField')],
    +              doc.attributes[this.state.get('latField')]
    +              ]
    +          };
    +        }
           }
           return null;
         }
    @@ -274,13 +285,15 @@ two fields with lat/lon values.

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

      _setupGeometryField: function(){
         var geomField, latField, lonField;
    -    this.state.set({
    -      geomField: this._checkField(this.geometryFieldNames),
    -      latField: this._checkField(this.latitudeFieldNames),
    -      lonField: this._checkField(this.longitudeFieldNames)
    -    });
    -    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
    -  },

    Private: Check if a field in the current model exists in the provided + this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));

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

        if (!this.geomReady) {
    +      this.state.set({
    +        geomField: this._checkField(this.geometryFieldNames),
    +        latField: this._checkField(this.latitudeFieldNames),
    +        lonField: this._checkField(this.longitudeFieldNames)
    +      });
    +      this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
    +    }
    +  },

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

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

    } } return null; - },

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

    + },

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

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

      _setupMap: function(){
    @@ -318,7 +331,7 @@ on OpenStreetMap.

    this.map.setView(new L.LatLng(0, 0), 2); this.mapReady = true; - },

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

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

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

      _selectOption: function(id,value){
         var options = $('.' + id + ' > select > option');
         if (options){
           options.each(function(opt){
    diff --git a/docs/view.html b/docs/view.html
    index 0ca33a89..34f9cb34 100644
    --- a/docs/view.html
    +++ b/docs/view.html
    @@ -180,8 +180,8 @@ initialized the DataExplorer with the relevant views themselves.

    initialize: function(options) { var self = this; - this.el = $(this.el);

    Hash of 'page' views (i.e. those for whole page) keyed by page name

        this._setupState(options.state);
    -    if (options.views) {
    +    this.el = $(this.el);
    +    this._setupState(options.state);

    Hash of 'page' views (i.e. those for whole page) keyed by page name

        if (options.views) {
           this.pageViews = options.views;
         } else {
           this.pageViews = [{
    diff --git a/recline.js b/recline.js
    index a0efe02e..391296aa 100644
    --- a/recline.js
    +++ b/recline.js
    @@ -2871,6 +2871,32 @@ this.recline.Backend = this.recline.Backend || {};
         query: function(model, queryObj) {
         },
     
    +    // ### _makeRequest
    +    // 
    +    // Just $.ajax but in any headers in the 'headers' attribute of this
    +    // Backend instance. Example:
    +    //
    +    // 
    +    // var jqxhr = this._makeRequest({
    +    //   url: the-url
    +    // });
    +    // 
    + _makeRequest: function(data) { + var headers = this.get('headers'); + var extras = {}; + if (headers) { + extras = { + beforeSend: function(req) { + _.each(headers, function(value, key) { + req.setRequestHeader(key, value); + }); + } + }; + } + var data = _.extend(extras, data); + return $.ajax(data); + }, + // convenience method to convert simple set of documents / rows to a QueryResult _docsToQueryResult: function(rows) { var hits = _.map(rows, function(row) { @@ -2995,37 +3021,39 @@ this.recline.Backend = this.recline.Backend || {}; // // Connecting to [ElasticSearch](http://www.elasticsearch.org/). // - // To use this backend ensure your Dataset has one of the following - // attributes (first one found is used): + // Usage: + // + //
    +  // var backend = new recline.Backend.ElasticSearch({
    +  //   // optional as can also be provided by Dataset/Document
    +  //   url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
    +  //   // optional
    +  //   headers: {dict of headers to add to each request}
    +  // });
    +  //
    +  // @param {String} url: url for ElasticSearch type/table, e.g. for ES running
    +  // on localhost:9200 with index // twitter and type tweet it would be:
    +  // 
    +  // 
    http://localhost:9200/twitter/tweet
    + // + // This url is optional since the ES endpoint url may be specified on the the + // dataset (and on a Document by the document having a dataset attribute) by + // having one of the following (see also `_getESUrl` function): // //
       // elasticsearch_url
       // webstore_url
       // url
       // 
    - // - // This should point to the ES type url. E.G. for ES running on - // localhost:9200 with index twitter and type tweet it would be - // - //
    http://localhost:9200/twitter/tweet
    my.ElasticSearch = my.Base.extend({ __type__: 'elasticsearch', - readonly: true, - _getESUrl: function(dataset) { - var out = dataset.get('elasticsearch_url'); - if (out) return out; - out = dataset.get('webstore_url'); - if (out) return out; - out = dataset.get('url'); - return out; - }, + readonly: false, sync: function(method, model, options) { var self = this; if (method === "read") { if (model.__type__ == 'Dataset') { - var base = self._getESUrl(model); - var schemaUrl = base + '/_mapping'; - var jqxhr = $.ajax({ + var schemaUrl = self._getESUrl(model) + '/_mapping'; + var jqxhr = this._makeRequest({ url: schemaUrl, dataType: 'jsonp' }); @@ -3044,11 +3072,77 @@ this.recline.Backend = this.recline.Backend || {}; dfd.reject(arguments); }); return dfd.promise(); + } else if (model.__type__ == 'Document') { + var base = this._getESUrl(model.dataset) + '/' + model.id; + return this._makeRequest({ + url: base, + dataType: 'json' + }); + } + } else if (method === 'update') { + if (model.__type__ == 'Document') { + return this.upsert(model.toJSON(), this._getESUrl(model.dataset)); + } + } else if (method === 'delete') { + if (model.__type__ == 'Document') { + var url = this._getESUrl(model.dataset); + return this.delete(model.id, url); } - } else { - alert('This backend currently only supports read operations'); } }, + + // ### upsert + // + // create / update a document to ElasticSearch backend + // + // @param {Object} doc an object to insert to the index. + // @param {string} url (optional) url for ElasticSearch endpoint (if not + // defined called this._getESUrl() + upsert: function(doc, url) { + var data = JSON.stringify(doc); + url = url ? url : this._getESUrl(); + if (doc.id) { + url += '/' + doc.id; + } + return this._makeRequest({ + url: url, + type: 'POST', + data: data, + dataType: 'json' + }); + }, + + // ### delete + // + // Delete a document from the ElasticSearch backend. + // + // @param {Object} id id of object to delete + // @param {string} url (optional) url for ElasticSearch endpoint (if not + // provided called this._getESUrl() + delete: function(id, url) { + url = url ? url : this._getESUrl(); + url += '/' + id; + return this._makeRequest({ + url: url, + type: 'DELETE', + dataType: 'json' + }); + }, + + // ### _getESUrl + // + // get url to ElasticSearch endpoint (see above) + _getESUrl: function(dataset) { + if (dataset) { + var out = dataset.get('elasticsearch_url'); + if (out) return out; + out = dataset.get('webstore_url'); + if (out) return out; + out = dataset.get('url'); + return out; + } + return this.get('url'); + }, _normalizeQuery: function(queryObj) { var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj); if (out.q !== undefined && out.q.trim() === '') { @@ -3085,7 +3179,7 @@ this.recline.Backend = this.recline.Backend || {}; var queryNormalized = this._normalizeQuery(queryObj); var data = {source: JSON.stringify(queryNormalized)}; var base = this._getESUrl(model); - var jqxhr = $.ajax({ + var jqxhr = this._makeRequest({ url: base + '/_search', data: data, dataType: 'jsonp'