diff --git a/app/index.html b/app/index.html index db5b8917..29ff9781 100644 --- a/app/index.html +++ b/app/index.html @@ -60,6 +60,7 @@ +
diff --git a/app/js/app.js b/app/js/app.js index 807b3c63..956e6f53 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,7 +1,8 @@ -$(function() { +jQuery(function($) { var qs = recline.View.parseQueryString(window.location.search); + var dataest = null; if (qs.url) { - var dataset = new recline.Model.Dataset({ + dataset = new recline.Model.Dataset({ id: 'my-dataset', url: qs.url, webstore_url: qs.url @@ -12,67 +13,90 @@ $(function() { dataset = localDataset(); } - createExplorer(dataset); + var app = new ExplorerApp({ + model: dataset, + el: $('.recline-app') + }) Backbone.history.start(); - - // setup the loader menu in top bar - setupLoader(createExplorer); }); -// make Explorer creation / initialization in a function so we can call it -// again and again -function createExplorer(dataset) { - // remove existing data explorer view - var reload = false; - if (window.dataExplorer) { - window.dataExplorer.remove(); - reload = true; - } - window.dataExplorer = null; - var $el = $('
'); - $el.appendTo($('.data-explorer-here')); - var views = standardViews(dataset); - window.dataExplorer = new recline.View.DataExplorer({ - el: $el - , model: dataset - , views: views - }); - // 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) { - window.dataExplorer.router.navigate('graph'); - window.dataExplorer.router.navigate('', true); - } -} +var ExplorerApp = Backbone.View.extend({ + events: { + 'submit form.js-import-url': '_onImportURL', + 'submit .js-import-dialog-file form': '_onImportFile' + }, -// convenience function -function standardViews(dataset) { - var views = [ - { - id: 'grid', - label: 'Grid', - view: new recline.View.Grid({ - model: dataset - }) - }, - { - id: 'graph', - label: 'Graph', - view: new recline.View.Graph({ - model: dataset - }) - }, - { - id: 'map', - label: 'Map', - view: new recline.View.Map({ - model: dataset - }) + initialize: function() { + this.explorer = null; + this.explorerDiv = $('.data-explorer-here'); + this.createExplorer(this.model); + }, + + // make Explorer creation / initialization in a function so we can call it + // again and again + createExplorer: function(dataset) { + // remove existing data explorer view + var reload = false; + if (this.dataExplorer) { + this.dataExplorer.remove(); + reload = true; } + this.dataExplorer = null; + var $el = $('
'); + $el.appendTo(this.explorerDiv); + this.dataExplorer = new recline.View.DataExplorer({ + el: $el + , model: dataset + }); + // 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); + } + }, - ]; - return views; -} + // setup the loader menu in top bar + setupLoader: function(callback) { + // pre-populate webstore load form with an example url + var demoUrl = 'http://thedatahub.org/api/data/b9aae52b-b082-4159-b46f-7bb9c158d013'; + $('form.js-import-url input[name="source"]').val(demoUrl); + }, + + _onImportURL: function(e) { + e.preventDefault(); + $('.modal.js-import-dialog-url').modal('hide'); + var $form = $(e.target); + var source = $form.find('input[name="source"]').val(); + var type = $form.find('select[name="backend_type"]').val(); + var dataset = new recline.Model.Dataset({ + id: 'my-dataset', + url: source, + webstore_url: source + }, + type + ); + this.createExplorer(dataset); + }, + + _onImportFile: function(e) { + var self = this; + e.preventDefault(); + var $form = $(e.target); + $('.modal.js-import-dialog-file').modal('hide'); + var $file = $form.find('input[type="file"]')[0]; + var file = $file.files[0]; + var options = { + separator : $form.find('input[name="separator"]').val(), + encoding : $form.find('input[name="encoding"]').val() + }; + recline.Backend.loadFromCSVFile(file, function(dataset) { + self.createExplorer(dataset) + }, + options + ); + } +}); // provide a demonstration in memory dataset function localDataset() { @@ -83,7 +107,7 @@ function localDataset() { , name: '1-my-test-dataset' , id: datasetId }, -fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}, {id: 'country'}, {id: 'label'},{id: 'lat'},{id: 'lon'}], + fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}, {id: 'country'}, {id: 'label'},{id: 'lat'},{id: 'lon'}], documents: [ {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} @@ -100,40 +124,3 @@ fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}, {id: 'country'}, {id: 'label'},{id: 'l return dataset; } -// setup the loader menu in top bar -function setupLoader(callback) { - // pre-populate webstore load form with an example url - var demoUrl = 'http://thedatahub.org/api/data/b9aae52b-b082-4159-b46f-7bb9c158d013'; - $('form.js-import-url input[name="source"]').val(demoUrl); - $('form.js-import-url').submit(function(e) { - e.preventDefault(); - $('.modal.js-import-dialog-url').modal('hide'); - var $form = $(e.target); - var source = $form.find('input[name="source"]').val(); - var type = $form.find('select[name="backend_type"]').val(); - var dataset = new recline.Model.Dataset({ - id: 'my-dataset', - url: source, - webstore_url: source - }, - type - ); - callback(dataset); - }); - - $('.js-import-dialog-file form').submit(function(e) { - e.preventDefault(); - var $form = $(e.target); - $('.modal.js-import-dialog-file').modal('hide'); - var $file = $form.find('input[type="file"]')[0]; - var file = $file.files[0]; - var options = { - separator : $form.find('input[name="separator"]').val(), - encoding : $form.find('input[name="encoding"]').val() - }; - recline.Backend.loadFromCSVFile(file, function(dataset) { - callback(dataset) - }, options); - }); -} - diff --git a/css/graph.css b/css/graph.css index f224603f..88acf5f8 100644 --- a/css/graph.css +++ b/css/graph.css @@ -1,14 +1,14 @@ -.recline-graph-container .graph { +.recline-graph .graph { height: 500px; margin-right: 200px; } -.recline-graph-container .legend table { +.recline-graph .legend table { width: auto; margin-bottom: 0; } -.recline-graph-container .legend td { +.recline-graph .legend td { padding: 5px; line-height: 13px; } @@ -17,34 +17,34 @@ * Editor *********************************************************/ -.recline-graph-container .editor { +.recline-graph .editor { float: right; width: 200px; padding-left: 0px; } -.recline-graph-container .editor-info { +.recline-graph .editor-info { padding-left: 4px; } -.recline-graph-container .editor-info { +.recline-graph .editor-info { cursor: pointer; } -.recline-graph-container .editor form { +.recline-graph .editor form { padding-left: 4px; } -.recline-graph-container .editor select { +.recline-graph .editor select { width: 100%; } -.recline-graph-container .editor-info { +.recline-graph .editor-info { border-bottom: 1px solid #ddd; margin-bottom: 10px; } -.recline-graph-container .editor-hide-info p { +.recline-graph .editor-hide-info p { display: none; } diff --git a/css/grid.css b/css/grid.css index fd525be6..aeb9984e 100644 --- a/css/grid.css +++ b/css/grid.css @@ -23,6 +23,10 @@ text-align: left; } +.recline-grid td { + vertical-align: top; +} + .recline-grid tr td:first-child, .recline-grid tr th:first-child { width: 20px; } @@ -63,10 +67,6 @@ float: right; } -.read-only a.row-header-menu { - display: none; -} - div.data-table-cell-content { line-height: 1.2; color: #222; @@ -301,15 +301,19 @@ td.expression-preview-value { * Read-only mode *********************************************************/ -.read-only .no-hidden .recline-grid tr td:first-child, -.read-only .no-hidden .recline-grid tr th:first-child +.recline-read-only .no-hidden .recline-grid tr td:first-child, +.recline-read-only .no-hidden .recline-grid tr th:first-child { display: none; } -.read-only .recline-grid .write-op, -.read-only .recline-grid a.data-table-cell-edit +.recline-read-only .recline-grid .write-op, +.recline-read-only .recline-grid a.data-table-cell-edit { display: none; } +.recline-read-only a.row-header-menu { + display: none; +} + diff --git a/css/map.css b/css/map.css index ed5edbd7..f1f2da29 100644 --- a/css/map.css +++ b/css/map.css @@ -1,4 +1,4 @@ -.data-map-container .map { +.recline-map .map { height: 500px; } @@ -6,22 +6,22 @@ * Editor *********************************************************/ -.data-map-container .editor { +.recline-map .editor { float: right; width: 200px; padding-left: 0px; margin-left: 10px; } -.data-map-container .editor form { +.recline-map .editor form { padding-left: 4px; } -.data-map-container .editor select { +.recline-map .editor select { width: 100%; } -.data-map-container .editor .editor-options { +.recline-map .editor .editor-options { margin-top: 10px; border-top: 1px solid gray; padding: 5px 0; diff --git a/index.html b/index.html index 30c80bf8..db4c4b93 100644 --- a/index.html +++ b/index.html @@ -182,13 +182,9 @@ Backbone.history.start();

Creating a Dataset Explicitly with a Backend

-// Backend can be an instance or string id for a backend in the
-// recline.Model.backends registry
-var backend = 'elasticsearch'
-// alternatively you can create explicitly
-// var backend = new recline.Backend.ElasticSearch();
-// or even from your own backend ...
-// var backend = new myModule.Backend();
+// Connect to ElasticSearch index/type as our data source
+// There are many other backends you can use (and you can write your own)
+var backend = new recline.Backend.ElasticSearch();
 
 // Dataset is a Backbone model so the first hash become model attributes
 var dataset = recline.Model.Dataset({
diff --git a/src/backend/base.js b/src/backend/base.js
index ec4d8412..e9c4eea7 100644
--- a/src/backend/base.js
+++ b/src/backend/base.js
@@ -17,10 +17,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
     //
diff --git a/src/backend/dataproxy.js b/src/backend/dataproxy.js
index 794b8e79..8f2b7496 100644
--- a/src/backend/dataproxy.js
+++ b/src/backend/dataproxy.js
@@ -17,6 +17,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'
     },
@@ -71,7 +72,5 @@ this.recline.Backend = this.recline.Backend || {};
       return dfd.promise();
     }
   });
-  recline.Model.backends['dataproxy'] = new my.DataProxy();
-
 
 }(jQuery, this.recline.Backend));
diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js
index 6944a2f4..164393a8 100644
--- a/src/backend/elasticsearch.js
+++ b/src/backend/elasticsearch.js
@@ -20,6 +20,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; @@ -115,7 +116,6 @@ this.recline.Backend = this.recline.Backend || {}; return dfd.promise(); } }); - recline.Model.backends['elasticsearch'] = new my.ElasticSearch(); }(jQuery, this.recline.Backend)); diff --git a/src/backend/gdocs.js b/src/backend/gdocs.js index 8cf0407c..e6f29b55 100644 --- a/src/backend/gdocs.js +++ b/src/backend/gdocs.js @@ -17,6 +17,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) { @@ -134,7 +135,6 @@ this.recline.Backend = this.recline.Backend || {}; return results; } }); - recline.Model.backends['gdocs'] = new my.GDoc(); }(jQuery, this.recline.Backend)); diff --git a/src/backend/memory.js b/src/backend/memory.js index 49f03087..f79991e8 100644 --- a/src/backend/memory.js +++ b/src/backend/memory.js @@ -20,7 +20,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 @@ -35,7 +35,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; }; @@ -70,6 +70,7 @@ this.recline.Backend = this.recline.Backend || {}; // etc ... // my.Memory = my.Base.extend({ + __type__: 'memory', initialize: function() { this.datasets = {}; }, @@ -209,6 +210,5 @@ this.recline.Backend = this.recline.Backend || {}; return facetResults; } }); - recline.Model.backends['memory'] = new my.Memory(); }(jQuery, this.recline.Backend)); diff --git a/src/model.js b/src/model.js index 5f834cad..1464ec46 100644 --- a/src/model.js +++ b/src/model.js @@ -18,7 +18,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 @@ -28,14 +28,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(); @@ -99,9 +109,68 @@ 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: {result of dataset.toJSON()} +// ... +// } +my.Dataset.restore = function(state) { + // hack-y - restoring a memory dataset does not mean much ... + var dataset = null; + if (state.backend === 'memory') { + dataset = recline.Backend.createDataset( + [{stub: 'this is a stub dataset because we do not restore memory datasets'}], + [], + state.dataset + ); + } else { + dataset = new recline.Model.Dataset( + state.dataset, + state.backend + ); + } + return dataset; +}; + // ## A Document (aka Row) // // A single entry or row in the dataset diff --git a/src/view-graph.js b/src/view-graph.js index 9e54d213..08695a92 100644 --- a/src/view-graph.js +++ b/src/view-graph.js @@ -23,7 +23,7 @@ this.recline.View = this.recline.View || {}; my.Graph = Backbone.View.extend({ tagName: "div", - className: "recline-graph-container", + className: "recline-graph", template: ' \
\ @@ -275,9 +275,9 @@ my.Graph = Backbone.View.extend({ createSeries: function () { var self = this; var series = []; - $.each(this.state.attributes.series, function (seriesIndex, field) { + _.each(this.state.attributes.series, function(field) { var points = []; - $.each(self.model.currentDocuments.models, function (index, doc) { + _.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') { diff --git a/src/view-map.js b/src/view-map.js index 32b11e6a..9c930b80 100644 --- a/src/view-map.js +++ b/src/view-map.js @@ -12,21 +12,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: ' \
\ @@ -101,14 +101,12 @@ my.Map = Backbone.View.extend({ 'change #editor-auto-zoom': 'onAutoZoomChange' }, - - 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); @@ -136,9 +134,17 @@ my.Map = Backbone.View.extend({ self.visible = false; }); + var stateData = _.extend({ + geomField: null, + lonField: null, + latField: null + }, + options.state + ); + this.state = new recline.Model.ObjectState(stateData); + this.autoZoom = true; this.mapReady = false; - this.render(); }, @@ -155,12 +161,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(); } } @@ -189,9 +195,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){ @@ -227,14 +231,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; @@ -327,16 +336,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')] ] }; } @@ -350,12 +359,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 diff --git a/src/view.js b/src/view.js index b78e1901..27df520e 100644 --- a/src/view.js +++ b/src/view.js @@ -1,16 +1,83 @@ /*jshint multistr:true */ -// # Core View Functionality plus Data Explorer +// # Recline Views // -// ## Common view concepts +// 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 // -// TODO +// State information exists in order to support state serialization into the +// url or elsewhere and reloading of application from a stored state. // -// ### Read-only +// 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 // -// TODO +// See the existing Views. +// +// ---- + +// Standard JS module setup this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -38,7 +105,8 @@ this.recline.View = this.recline.View || {}; // // **views**: (optional) the dataset views (Grid, Graph etc) for // DataExplorer to show. This is an array of view hashes. If not provided -// just initialize a Grid with id 'grid'. Example: +// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id +// and labels!). // //
 // var views = [
@@ -59,10 +127,25 @@ this.recline.View = this.recline.View || {};
 // ];
 // 
// -// **state**: state config for this view. Options are: +// **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
+// }
+// 
+// +// 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: ' \
\ @@ -71,7 +154,7 @@ my.DataExplorer = Backbone.View.extend({
\ \
\ @@ -94,7 +177,8 @@ my.DataExplorer = Backbone.View.extend({
\ ', events: { - 'click .menu-right a': 'onMenuClick' + 'click .menu-right a': '_onMenuClick', + 'click .navigation a': '_onSwitchView' }, initialize: function(options) { @@ -108,14 +192,26 @@ my.DataExplorer = Backbone.View.extend({ id: 'grid', label: 'Grid', view: new my.Grid({ - model: this.model - }) + model: this.model + }) + }, { + id: 'graph', + label: 'Graph', + view: new my.Graph({ + model: this.model + }) + }, { + id: 'map', + label: 'Map', + view: new my.Map({ + model: this.model + }) }]; } - this.state = new recline.Model.ObjectState(); // these must be called after pageViews are created - this._setupState(options.state); this.render(); + // should come after render as may need to interact with elements in the view + this._setupState(options.state); this.router = new Backbone.Router(); this.setupRouting(); @@ -163,7 +259,7 @@ my.DataExplorer = Backbone.View.extend({ }, setReadOnly: function() { - this.el.addClass('read-only'); + this.el.addClass('recline-read-only'); }, render: function() { @@ -204,10 +300,10 @@ my.DataExplorer = Backbone.View.extend({ }); }, - 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 @@ -222,7 +318,7 @@ my.DataExplorer = Backbone.View.extend({ }); }, - onMenuClick: function(e) { + _onMenuClick: function(e) { e.preventDefault(); var action = $(e.target).attr('data-action'); if (action === 'filters') { @@ -232,27 +328,47 @@ my.DataExplorer = Backbone.View.extend({ } }, + _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({ - readOnly: false, query: query, 'view-graph': graphState, - currentView: null + backend: this.model.backend.__type__, + dataset: this.model.toJSON(), + currentView: this.pageViews[0].id, + readOnly: false }, initialState); - this.state.set(stateData); + 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) { @@ -260,7 +376,7 @@ my.DataExplorer = Backbone.View.extend({ } }); - // bind for changes state in associated objects + // 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()}); }); @@ -276,14 +392,21 @@ my.DataExplorer = Backbone.View.extend({ }); } }); - }, - - // Get the current state of dataset and views - getState: function() { - return this.state; } }); +// ### 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: ' \ diff --git a/test/backend.elasticsearch.test.js b/test/backend.elasticsearch.test.js index 9af34e2d..0a132d0c 100644 --- a/test/backend.elasticsearch.test.js +++ b/test/backend.elasticsearch.test.js @@ -108,10 +108,11 @@ var sample_data = { }; test("ElasticSearch", function() { + var backend = new recline.Backend.ElasticSearch(); var dataset = new recline.Model.Dataset({ url: 'https://localhost:9200/my-es-db/my-es-type' }, - 'elasticsearch' + backend ); var stub = sinon.stub($, 'ajax', function(options) { diff --git a/test/backend.test.js b/test/backend.test.js index ee298194..f7d96f60 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -103,7 +103,6 @@ test('Memory Backend: filters', function () { }); test('Memory Backend: facet', function () { - console.log('here'); var dataset = makeBackendDataset(); dataset.queryState.addFacet('country'); dataset.query().then(function() { @@ -217,10 +216,11 @@ var dataProxyData = { test('DataProxy Backend', function() { // needed only if not stubbing // stop(); + var backend = new recline.Backend.DataProxy(); var dataset = new recline.Model.Dataset({ url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv' }, - 'dataproxy' + backend ); var stub = sinon.stub($, 'ajax', function(options) { @@ -419,10 +419,11 @@ var sample_gdocs_spreadsheet_data = { } test("GDoc Backend", function() { + var backend = new recline.Backend.GDoc(); var dataset = new recline.Model.Dataset({ url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json' }, - 'gdocs' + backend ); var stub = sinon.stub($, 'getJSON', function(options, cb) { @@ -450,7 +451,7 @@ test("GDoc Backend.getUrl", function() { var dataset = new recline.Model.Dataset({ url: 'https://docs.google.com/spreadsheet/ccc?key=' + key + '#gid=0' }); - var backend = recline.Model.backends['gdocs']; + var backend = new recline.Backend.GDoc(); var out = backend.getUrl(dataset); var exp = 'https://spreadsheets.google.com/feeds/list/' + key + '/1/public/values?alt=json' equal(exp, out); diff --git a/test/index.html b/test/index.html index a3c508d5..da0e90b8 100644 --- a/test/index.html +++ b/test/index.html @@ -4,12 +4,15 @@ Qunit Tests + + + @@ -19,20 +22,23 @@ - + + + + diff --git a/test/model.test.js b/test/model.test.js index 2625a7a4..2f0c4e6a 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -103,6 +103,16 @@ test('Dataset _prepareQuery', function () { deepEqual(out, exp); }); +test('Dataset _backendFromString', function () { + var dataset = new recline.Model.Dataset(); + + var out = dataset._backendFromString('recline.Backend.Memory'); + equal(out.__type__, 'memory'); + + var out = dataset._backendFromString('dataproxy'); + equal(out.__type__, 'dataproxy'); +}); + // ================================= // Query diff --git a/test/view-grid.test.js b/test/view-grid.test.js index b415acde..4d2d0a96 100644 --- a/test/view-grid.test.js +++ b/test/view-grid.test.js @@ -53,6 +53,7 @@ test('new GridRow View', function () { var tds = $el.find('td'); equal(tds.length, 3); equal($(tds[1]).attr('data-field'), 'a'); + view.remove(); }); })(this.jQuery); diff --git a/test/view.test.js b/test/view.test.js index 6a898989..cb0d4a0c 100644 --- a/test/view.test.js +++ b/test/view.test.js @@ -15,7 +15,7 @@ test('basic explorer functionality', function () { $el.remove(); }); -test('getState', function () { +test('get State', function () { var $el = $('
'); $('.fixtures .data-explorer-here').append($el); var dataset = Fixture.getDataset(); @@ -23,27 +23,52 @@ test('getState', function () { model: dataset, el: $el }); - var state = explorer.getState(); + var state = explorer.state; ok(state.get('query')); equal(state.get('readOnly'), false); + equal(state.get('currentView'), 'grid'); equal(state.get('query').size, 100); deepEqual(state.get('view-grid').hiddenFields, []); + equal(state.get('backend'), 'memory'); + ok(state.get('dataset').id !== null); $el.remove(); }); test('initialize state', function () { + var $el = $('
'); + $('.fixtures .data-explorer-here').append($el); var dataset = Fixture.getDataset(); var explorer = new recline.View.DataExplorer({ model: dataset, + el: $el, state: { readOnly: true, + currentView: 'graph', 'view-grid': { hiddenFields: ['x'] } } }); - var state = explorer.getState(); - ok(state.get('readOnly')); + 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); + $el.remove(); +}); + +test('restore (from serialized state)', function() { + var dataset = Fixture.getDataset(); + var explorer = new recline.View.DataExplorer({ + model: dataset, + }); + var state = explorer.state.toJSON(); + var explorerNew = recline.View.DataExplorer.restore(state); + var out = explorerNew.state.toJSON(); + equal(out.backend, state.backend); }); })(this.jQuery);