diff --git a/README.md b/README.md index 00dc3d9f..aeb750ff 100755 --- a/README.md +++ b/README.md @@ -1,26 +1,60 @@ -# Recline - -A pure javascript data explorer and data refinery. Imagine it as a spreadsheet plus Google Refine plus Visualization toolkit, all in pure javascript and html. +Recline DataExplorer is an open-source pure javascript data explorer and data +refinery. Imagine it as a spreadsheet plus Google Refine plus Visualization +toolkit, all in pure javascript and html. Designed for standalone use or as a library to integrate into your own app. + ## Features -* CSV/JSON export your entire database for integration with spreadsheets or - [Google Refine](http://code.google.com/p/google-refine/) +* Open-source (and heavy reuser of existing open-source libraries) +* Pure javascript (no Flash) and designed for integration -- so it is easy to + embed in other sites and applications +* View and edit your data in clean tabular interface * Bulk update/clean your data using an easy scripting UI -* Import by directly downloading from JSON APIs or by uploading files +* Visualize your data ![screenshot](http://i.imgur.com/XDSRe.png) + ## Demo App Open demo/index.html in your favourite browser. -## Minifying dependencies + +## Developer Notes + +### Minifying dependencies npm install -g uglify cd vendor cat *.js | uglifyjs -o ../src/deps-min.js note: make sure underscore.js goes in at the top of the file as a few deps currently depend on it + + +## Copyright and License + +Copyright 2011 Max Ogden and Rufus Pollock. + +Licensed under the MIT license: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + diff --git a/demo/js/app.js b/demo/js/app.js index 21f360bc..1bf6b6f6 100755 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -3,14 +3,14 @@ $(function() { // window.$container = $('.container .right-panel'); window.$container = $('.container'); var dataset = demoDataset(); - window.dataExplorer = new recline.DataExplorer({ + window.dataExplorer = new recline.View.DataExplorer({ model: dataset }); window.$container.append(window.dataExplorer.el); setupLoadFromWebstore(function(dataset) { window.dataExplorer.remove(); window.dataExplorer = null; - window.dataExplorer = new recline.DataExplorer({ + window.dataExplorer = new recline.View.DataExplorer({ model: dataset, }); window.$container.append(window.dataExplorer.el); @@ -27,21 +27,20 @@ function demoDataset() { var indata = { headers: ['x', 'y', 'z'] , rows: [ - {x: 1, y: 2, z: 3} - , {x: 2, y: 4, z: 6} - , {x: 3, y: 6, z: 9} - , {x: 4, y: 8, z: 12} - , {x: 5, y: 10, z: 15} - , {x: 6, y: 12, z: 18} + {id: 0, x: 1, y: 2, z: 3} + , {id: 1, x: 2, y: 4, z: 6} + , {id: 2, x: 3, y: 6, z: 9} + , {id: 3, x: 4, y: 8, z: 12} + , {id: 4, x: 5, y: 10, z: 15} + , {id: 5, x: 6, y: 12, z: 18} ] }; // this is all rather artificial here but would make more sense with more complex backend - var backend = new recline.BackendMemory(); - backend.addDataset({ + var backend = new recline.Model.BackendMemory({ metadata: metadata, data: indata }); - recline.setBackend(backend); + recline.Model.setBackend(backend); var dataset = backend.getDataset(datasetId); return dataset; } @@ -54,10 +53,10 @@ function setupLoadFromWebstore(callback) { e.preventDefault(); var $form = $(e.target); var source = $form.find('input[name="source"]').val(); - var backend = new recline.BackendWebstore({ + var backend = new recline.Model.BackendWebstore({ url: source }); - recline.setBackend(backend); + recline.Model.setBackend(backend); var dataset = backend.getDataset(); callback(dataset); }); diff --git a/demo/style/style.css b/demo/style/style.css index 5e073b3a..40c295f4 100755 --- a/demo/style/style.css +++ b/demo/style/style.css @@ -239,3 +239,13 @@ span.tooltip-status { right: 0px; background: white; } + +.nav-pagination { + display: inline; + float: right; + list-style-type: none; +} + +.nav-pagination input { + width: 40px; +} diff --git a/src/backbone-webstore.js b/src/backbone-webstore.js deleted file mode 100644 index 14d02ae3..00000000 --- a/src/backbone-webstore.js +++ /dev/null @@ -1,48 +0,0 @@ -// replaces `Backbone.sync` with a OKFN webstore based tabular data source - -var WebStore = function(url) { - this.url = url; - this.headers = []; - this.totalRows = 0; - this.getTabularData = function() { - var dfd = $.Deferred(); - var tabularData = { - headers: ['x', 'y', 'z'] - , rows: [ - {x: 1, y: 2, z: 3} - , {x: 2, y: 4, z: 6} - , {x: 3, y: 6, z: 9} - , {x: 4, y: 8, z: 12} - , {x: 5, y: 10, z: 15} - , {x: 6, y: 12, z: 18} - ] - , getLength: function() { return this.rows.length; } - , getRows: function(numRows, start) { - if (start === undefined) { - start = 0; - } - var dfd = $.Deferred(); - var results = this.rows.slice(start, start + numRows); - dfd.resolve(results); - return dfd.promise(); - } - } - dfd.resolve(tabularData); - return dfd.promise(); - } -}; - - -// Override `Backbone.sync` to delegate to the model or collection's -// webStore property, which should be an instance of `WebStore`. -Backbone.sync = function(method, model, options) { - var resp; - var store = model.webStore || model.collection.webStore; - - if (method === "read") { - store.getTabularData().then(function(tabularData) { - tabularData.getRows(10).then(options.success, options.error) - }) - } - -}; \ No newline at end of file diff --git a/src/costco.js b/src/costco.js index 7c5b089c..edce7590 100755 --- a/src/costco.js +++ b/src/costco.js @@ -25,6 +25,7 @@ var costco = function() { preview.push({before: JSON.stringify(before), after: JSON.stringify(after)}); } } + // TODO: 2012-01-05 Move this out of this function and up into (view) functions that call this util.render('editPreview', 'expression-preview-container', {rows: preview}); } @@ -166,4 +167,4 @@ var costco = function() { ensureCommit: ensureCommit, uploadCSV: uploadCSV }; -}(); \ No newline at end of file +}(); diff --git a/src/model.js b/src/model.js index 83f4a49a..ba143881 100644 --- a/src/model.js +++ b/src/model.js @@ -1,61 +1,105 @@ this.recline = this.recline || {}; +// Models module following classic module pattern +recline.Model = function($) { + +var my = {}; + // A Dataset model. -recline.Dataset = Backbone.Model.extend({ +my.Dataset = Backbone.Model.extend({ __type__: 'Dataset', + initialize: function() { + this.currentDocuments = new my.DocumentList(); + }, + getLength: function() { return this.rowCount; }, + + // Get rows (documents) from the backend returning a recline.DocumentList + // + // TODO: ? rename to getDocuments? + // // this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. // This also illustrates the limitations of separating the Dataset and the Backend getRows: function(numRows, start) { - return this.backend.getRows(this.id, numRows, start); + var self = this; + var dfd = $.Deferred(); + this.backend.getRows(this.id, numRows, start).then(function(rows) { + var docs = _.map(rows, function(row) { + return new my.Document(row); + }); + self.currentDocuments.reset(docs); + dfd.resolve(self.currentDocuments); + }); + return dfd.promise(); } }); -recline.Document = Backbone.Model.extend({}); +my.Document = Backbone.Model.extend({ + __type__: 'Document' +}); -recline.DocumentList = Backbone.Collection.extend({ +my.DocumentList = Backbone.Collection.extend({ + __type__: 'DocumentList', // webStore: new WebStore(this.url), - model: recline.Document -}) + model: my.Document +}); // Backends section // ================ -recline.setBackend = function(backend) { +my.setBackend = function(backend) { Backbone.sync = backend.sync; }; // Backend which just caches in memory // // Does not need to be a backbone model but provides some conveience -recline.BackendMemory = Backbone.Model.extend({ - initialize: function() { - this._datasetCache = {} +my.BackendMemory = Backbone.Model.extend({ + // Initialize a Backend with a local in-memory dataset. + // + // NB: We can handle one and only one dataset at a time. + // + // :param dataset: the data for a dataset on which operations will be + // performed. In the form of a hash with metadata and data attributes. + // - metadata: hash of key/value attributes of any kind (but usually with title attribute) + // - data: hash with 2 keys: + // - headers: list of header names/labels + // - rows: list of hashes, each hash being one row. A row *must* have an id attribute which is unique. + // + // Example of data: + // + // { + // headers: ['x', 'y', 'z'] + // , rows: [ + // {id: 0, x: 1, y: 2, z: 3} + // , {id: 1, x: 2, y: 4, z: 6} + // ] + // }; + initialize: function(dataset) { + // deep copy + this._datasetAsData = $.extend(true, {}, dataset); + _.bindAll(this, 'sync'); }, - // dataset is object with metadata and data attributes - addDataset: function(dataset) { - this._datasetCache[dataset.metadata.id] = dataset; - }, - getDataset: function(id) { - var dataset = new recline.Dataset({ - id: id + getDataset: function() { + var dataset = new my.Dataset({ + id: this._datasetAsData.metadata.id }); // this is a bit weird but problem is in sync this is set to parent model object so need to give dataset a reference to backend explicitly dataset.backend = this; return dataset; }, sync: function(method, model, options) { + var self = this; if (method === "read") { var dfd = $.Deferred(); // this switching on object type is rather horrible // think may make more sense to do work in individual objects rather than in central Backbone.sync - if (this.__type__ == 'Dataset') { - var dataset = this; - var rawDataset = this.backend._datasetCache[model.id]; + if (model.__type__ == 'Dataset') { + var dataset = model; + var rawDataset = this._datasetAsData; dataset.set(rawDataset.metadata); - // here we munge it all onto Dataset dataset.set({ headers: rawDataset.data.headers }); @@ -63,6 +107,28 @@ recline.BackendMemory = Backbone.Model.extend({ dfd.resolve(dataset); } return dfd.promise(); + } else if (method === 'update') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + _.each(this._datasetAsData.data.rows, function(row, idx) { + if(row.id === model.id) { + self._datasetAsData.data.rows[idx] = model.toJSON(); + } + }); + dfd.resolve(model); + } + return dfd.promise(); + } else if (method === 'delete') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + this._datasetAsData.data.rows = _.reject(this._datasetAsData.data.rows, function(row) { + return (row.id === model.id); + }); + dfd.resolve(model); + } + return dfd.promise(); + } else { + alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); } }, getRows: function(datasetId, numRows, start) { @@ -73,7 +139,7 @@ recline.BackendMemory = Backbone.Model.extend({ numRows = 10; } var dfd = $.Deferred(); - rows = this._datasetCache[datasetId].data.rows; + rows = this._datasetAsData.data.rows; var results = rows.slice(start, start+numRows); dfd.resolve(results); return dfd.promise(); @@ -82,15 +148,15 @@ recline.BackendMemory = Backbone.Model.extend({ // Webstore Backend for connecting to the Webstore // -// Designed to only attached to only dataset and one dataset only ... +// Designed to only attach to one dataset and one dataset only ... // Could generalize to support attaching to different datasets -recline.BackendWebstore = Backbone.Model.extend({ +my.BackendWebstore = Backbone.Model.extend({ // require url attribute in initialization data initialize: function() { this.webstoreTableUrl = this.get('url'); }, getDataset: function(id) { - var dataset = new recline.Dataset({ + var dataset = new my.Dataset({ id: id }); dataset.backend = this; @@ -146,3 +212,8 @@ recline.BackendWebstore = Backbone.Model.extend({ return dfd.promise(); } }); + +return my; + +}(jQuery); + diff --git a/src/recline.js b/src/recline.js index 62e4a132..967788b8 100755 --- a/src/recline.js +++ b/src/recline.js @@ -1,41 +1,5 @@ -window.recline = {}; - -recline.Document = Backbone.Model.extend({}); - -recline.DocumentList = Backbone.Collection.extend({ - webStore: new WebStore(this.url), - model: recline.Document -}); - -recline.DataTable = Backbone.View.extend({ - - el: ".data-table-container", - - documents: new recline.DocumentList(this.url), - - // template: TODO ??? - - events: { - - }, - - initialize: function() { - var that = this; - this.documents.fetch({ - success: function(collection, resp) { - that.render() - } - }) - }, - - render: function() { - var template = $( ".dataTableTemplate:first" ).html() - , htmls = $.mustache(template, {rows: this.documents.toJSON()} ) - ; - $(this.el).html(htmls); - return this; - } -}); +// Original Recline code - **Deprecated** +// Left intact while functionality is transferred across to new Backbone setup // var recline = function() { // diff --git a/src/view.js b/src/view.js index f580beae..e416d697 100644 --- a/src/view.js +++ b/src/view.js @@ -1,6 +1,11 @@ this.recline = this.recline || {}; -recline.DataExplorer = Backbone.View.extend({ +// Views module following classic module pattern +recline.View = function($) { + +var my = {}; + +my.DataExplorer = Backbone.View.extend({ tagName: 'div', className: 'data-explorer', template: ' \ @@ -11,37 +16,58 @@ recline.DataExplorer = Backbone.View.extend({ \ \ \ + \ \
\ ', events: { - 'change input[name="nav-toggle"]': 'navChange' + 'change input[name="nav-toggle"]': 'navChange', + 'submit form.display-count': 'displayCountUpdate' }, - initialize: function() { + initialize: function(options) { this.el = $(this.el); + this.config = options.config || {}; + _.extend(this.config, { + displayCount: 10 + }); + this.draw(); + }, + + displayCountUpdate: function(e) { + e.preventDefault(); + this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); + this.draw(); + }, + + draw: function() { + var self = this; + this.el.empty(); this.render(); this.$dataViewContainer = this.el.find('.data-view-container'); - var self = this; // retrieve basic data like headers etc // note this.model and dataset returned are the same this.model.fetch().then(function(dataset) { // initialize of dataTable calls render - self.dataTable = new recline.DataTable({ + self.dataTable = new my.DataTable({ model: dataset }); - self.flotGraph = new recline.FlotGraph({ + self.flotGraph = new my.FlotGraph({ model: dataset }); self.flotGraph.el.hide(); self.$dataViewContainer.append(self.dataTable.el) self.$dataViewContainer.append(self.flotGraph.el); + self.model.getRows(self.config.displayCount); }); }, render: function() { - $(this.el).html($(this.template)); + var template = $.mustache(this.template, this.config); + $(this.el).html(template); }, navChange: function(e) { @@ -62,17 +88,20 @@ recline.DataExplorer = Backbone.View.extend({ } }); -recline.DataTable = Backbone.View.extend({ +// DataTable provides a tabular view on a Dataset. +// +// Initialize it with a recline.Dataset object. +my.DataTable = Backbone.View.extend({ tagName: "div", className: "data-table-container", initialize: function() { - this.el = $(this.el); var self = this; - this.model.getRows().then(function(rows) { - self._currentRows = rows; - self.render() - }); + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); + this.model.currentDocuments.bind('remove', this.render); this.state = {}; // this is nasty. Due to fact that .menu element is not inside this view but is elsewhere in DOM $('.menu li a').live('click', function(e) { @@ -85,11 +114,7 @@ recline.DataTable = Backbone.View.extend({ // see initialize // 'click .menu li': 'onMenuClick', 'click .column-header-menu': 'onColumnHeaderClick', - 'click .row-header-menu': 'onRowHeaderClick', - 'click .data-table-cell-edit': 'onEditClick', - // cell editor - 'click .data-table-cell-editor .okButton': 'onEditorOK', - 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' + 'click .row-header-menu': 'onRowHeaderClick' }, showDialog: function(template, data) { @@ -122,8 +147,8 @@ recline.DataTable = Backbone.View.extend({ var self = this; e.preventDefault(); var actions = { - bulkEdit: function() { self.showDialog('bulkEdit', {name: self.state.currentColumn}) }, - transform: function() { showDialog('transform') }, + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + transform: function() { self.showTransformDialog('transform') }, csv: function() { window.location.href = app.csvUrl }, json: function() { window.location.href = "_rewrite/api/json" }, urlImport: function() { showDialog('urlImport') }, @@ -137,25 +162,146 @@ recline.DataTable = Backbone.View.extend({ if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); }, deleteRow: function() { - // TODO: - alert('This function needs to be re-implemented'); - return; - var doc = _.find(app.cache, function(doc) { return doc._id === app.currentRow }); - doc._deleted = true; - costco.uploadDocs([doc]).then( - function(updatedDocs) { + var doc = _.find(self.model.currentDocuments.models, function(doc) { + // important this is == as the currentRow will be string (as comes + // from DOM) while id may be int + return doc.id == self.state.currentRow + }); + doc.destroy().then(function() { + self.model.currentDocuments.remove(doc); util.notify("Row deleted successfully"); - recline.initializeTable(app.offset); - }, - function(err) { util.notify("Errorz! " + err) } - ) + }) + .fail(function(err) { + util.notify("Errorz! " + err) + }) } } - util.hide('menu'); actions[$(e.target).attr('data-action')](); }, + showTransformColumnDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.ColumnTransform({ + model: this.model + }); + view.state = this.state; + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + showTransformDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.DataTransform({ + }); + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + + // ====================================================== + // Core Templating + template: ' \ + \ + \ + \ + {{#notEmpty}}{{/notEmpty}} \ + {{#headers}} \ + \ + {{/headers}} \ + \ + \ + \ +
\ +
\ + \ + {{.}} \ +
\ + \ +
\ + ', + + toTemplateJSON: function() { + var modelData = this.model.toJSON() + modelData.notEmpty = ( modelData.headers.length > 0 ) + return modelData; + }, + render: function() { + var self = this; + var template = $( ".dataTableTemplate:first" ).html() + , htmls = $.mustache(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.DataTableRow({ + model: doc, + el: tr, + headers: self.model.get('headers') + }); + newView.render(); + }); + return this; + } +}); + +// DataTableRow View for rendering an individual document. +// +// Since we want this to update in place it is up to creator to provider the element to attach to. +// In addition you must pass in a headers in the constructor options. This should be list of headers for the DataTable. +my.DataTableRow = Backbone.View.extend({ + initialize: function(options) { + _.bindAll(this, 'render'); + this._headers = options.headers; + this.el = $(this.el); + this.model.bind('change', this.render); + }, + template: ' \ + \ + {{#cells}} \ + \ +
\ +   \ +
{{value}}
\ +
\ + \ + {{/cells}} \ + ', + events: { + 'click .data-table-cell-edit': 'onEditClick', + // cell editor + 'click .data-table-cell-editor .okButton': 'onEditorOK', + 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' + }, + + toTemplateJSON: function() { + var doc = this.model; + var cellData = _.map(this._headers, function(header) { + return {header: header, value: doc.get(header)} + }) + return { id: this.id, cells: cellData } + }, + + render: function() { + this.el.attr('data-id', this.model.id); + var html = $.mustache(this.template, this.toTemplateJSON()); + $(this.el).html(html); + return this; + }, + // ====================================================== // Cell Editor @@ -174,50 +320,225 @@ recline.DataTable = Backbone.View.extend({ var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); var header = cell.parents('td').attr('data-header'); - var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor)').val(); - // TODO: - alert('Update: ' + header + ' with value ' + newValue + '(But no save as not yet operational'); - return; - var doc = _.find(app.cache, function(cacheDoc) { - return cacheDoc._id === rowId; - }); - doc[header] = newValue; + var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); + var newData = {}; + newData[header] = newValue; + this.model.set(newData); util.notify("Updating row...", {persist: true, loader: true}); - costco.updateDoc(doc).then(function(response) { - util.notify("Row updated successfully"); - recline.initializeTable(); - }) + this.model.save().then(function(response) { + util.notify("Row updated successfully"); + }) + .fail(function() { + alert('error saving'); + }); }, onEditorCancel: function(e) { var cell = $(e.target).parents('.data-table-cell-value'); cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); - }, - - // ====================================================== - // Core Templating - - toTemplateJSON: function() { - var modelData = this.model.toJSON() - modelData.rows = _.map(this._currentRows, function(row) { - var cellData = _.map(modelData.headers, function(header) { - return {header: header, value: row[header]} - }) - return { id: 'xxx', cells: cellData } - }) - modelData.notEmpty = ( modelData.headers.length > 0 ) - return modelData; - }, - render: function() { - var template = $( ".dataTableTemplate:first" ).html() - , htmls = $.mustache(template, this.toTemplateJSON()) - ; - $(this.el).html(htmls); - return this; } }); -recline.FlotGraph = Backbone.View.extend({ + +// View (Dialog) for doing data transformations (on columns of data). +my.ColumnTransform = Backbone.View.extend({ + className: 'transform-column-view', + template: ' \ +
\ + Functional transform on column {{name}} \ +
\ +
\ +
\ + \ + \ + \ + \ + \ + \ +
\ +
\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
\ + Expression \ +
\ +
\ + \ +
\ +
\ + No syntax error. \ +
\ +
\ + Preview \ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ + \ + ', + + events: { + 'click .okButton': 'onSubmit' + , 'keydown .expression-preview-code': 'onEditorKeydown' + }, + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + var htmls = $.mustache(this.template, + {name: this.state.currentColumn} + ) + this.el.html(htmls); + // Put in the basic (identity) transform script + // TODO: put this into the template? + var editor = this.el.find('.expression-preview-code'); + editor.val("function(doc) {\n doc['"+ this.state.currentColumn+"'] = doc['"+ this.state.currentColumn+"'];\n return doc;\n}"); + editor.focus().get(0).setSelectionRange(18, 18); + editor.keydown(); + }, + + onSubmit: function(e) { + var self = this; + var funcText = this.el.find('.expression-preview-code').val(); + var editFunc = costco.evalFunction(funcText); + if (editFunc.errorMessage) { + util.notify("Error with function! " + editFunc.errorMessage); + return; + } + util.hide('dialog'); + util.notify("Updating all visible docs. This could take a while...", {persist: true, loader: true}); + var docs = self.model.currentDocuments.map(function(doc) { + return doc.toJSON(); + }); + // TODO: notify about failed docs? + var toUpdate = costco.mapDocs(docs, editFunc).edited; + var totalToUpdate = toUpdate.length; + function onCompletedUpdate() { + totalToUpdate += -1; + if (totalToUpdate === 0) { + util.notify(toUpdate.length + " documents updated successfully"); + alert('WARNING: We have only updated the docs in this view. (Updating of all docs not yet implemented!)'); + self.remove(); + } + } + // TODO: Very inefficient as we search through all docs every time! + _.each(toUpdate, function(editedDoc) { + var realDoc = self.model.currentDocuments.get(editedDoc.id); + realDoc.set(editedDoc); + realDoc.save().then(onCompletedUpdate).fail(onCompletedUpdate) + }); + }, + + onEditorKeydown: function(e) { + var self = this; + // if you don't setTimeout it won't grab the latest character if you call e.target.value + window.setTimeout( function() { + var errors = self.el.find('.expression-preview-parsing-status'); + var editFunc = costco.evalFunction(e.target.value); + if (!editFunc.errorMessage) { + errors.text('No syntax error.'); + var docs = self.model.currentDocuments.map(function(doc) { + return doc.toJSON(); + }); + costco.previewTransform(docs, editFunc, self.state.currentColumn); + } else { + errors.text(editFunc.errorMessage); + } + }, 1, true); + } +}); + +// View (Dialog) for doing data transformations on whole dataset. +my.DataTransform = Backbone.View.extend({ + className: 'transform-view', + template: ' \ +
\ + Recursive transform on all rows \ +
\ +
\ +
\ +

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

\ + \ + \ + \ + \ + \ + \ +
\ +
\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
\ + Expression \ +
\ +
\ + \ +
\ +
\ + No syntax error. \ +
\ +
\ + Preview \ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ + \ + ', + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + this.el.html(this.template); + } +}); + + +my.FlotGraph = Backbone.View.extend({ tagName: "div", className: "data-graph-container", @@ -272,18 +593,17 @@ recline.FlotGraph = Backbone.View.extend({ ', initialize: function(options, chart) { + var self = this; this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); this.chart = chart; this.chartConfig = { group: null, series: [], graphType: 'line' }; - var self = this; - this.model.getRows().then(function(rows) { - self._currentRows = rows; - self.render() - }); }, events: { @@ -342,8 +662,9 @@ recline.FlotGraph = Backbone.View.extend({ if (this.chartConfig) { $.each(this.chartConfig.series, function (seriesIndex, field) { var points = []; - $.each(self._currentRows, function (index) { - var x = this[self.chartConfig.group], y = this[field]; + $.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; } @@ -370,3 +691,8 @@ recline.FlotGraph = Backbone.View.extend({ return this; } }); + +return my; + +}(jQuery); + diff --git a/test/index.html b/test/index.html index 3c8d8a96..21d80027 100644 --- a/test/index.html +++ b/test/index.html @@ -12,6 +12,8 @@ + +

Qunit Tests

@@ -19,9 +21,9 @@

    -
    -
    -
    +
    + +
    diff --git a/test/model.test.js b/test/model.test.js index 452349c6..e696edcc 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -12,34 +12,48 @@ test('new Dataset', function () { var indata = { headers: ['x', 'y', 'z'] , rows: [ - {x: 1, y: 2, z: 3} - , {x: 2, y: 4, z: 6} - , {x: 3, y: 6, z: 9} - , {x: 4, y: 8, z: 12} - , {x: 5, y: 10, z: 15} - , {x: 6, y: 12, z: 18} + {id: 0, x: 1, y: 2, z: 3} + , {id: 1, x: 2, y: 4, z: 6} + , {id: 2, x: 3, y: 6, z: 9} + , {id: 3, x: 4, y: 8, z: 12} + , {id: 4, x: 5, y: 10, z: 15} + , {id: 5, x: 6, y: 12, z: 18} ] }; // this is all rather artificial here but would make more sense with more complex backend - backend = new recline.BackendMemory(); - backend.addDataset({ + backend = new recline.Model.BackendMemory({ metadata: metadata, data: indata }); - recline.setBackend(backend); + recline.Model.setBackend(backend); var dataset = backend.getDataset(datasetId); - expect(6); + expect(9); dataset.fetch().then(function(dataset) { equal(dataset.get('name'), metadata.name); - equal(dataset.get('headers'), indata.headers); + deepEqual(dataset.get('headers'), indata.headers); equal(dataset.getLength(), 6); - dataset.getRows(4, 2).then(function(rows) { - equal(rows[0], indata.rows[2]); + dataset.getRows(4, 2).then(function(documentList) { + deepEqual(indata.rows[2], documentList.models[0].toJSON()); }); - dataset.getRows().then(function(rows) { - equal(rows.length, Math.min(10, indata.rows.length)); - equal(rows[0], indata.rows[0]); + dataset.getRows().then(function(docList) { + // Test getRows + equal(docList.length, Math.min(10, indata.rows.length)); + var doc1 = docList.models[0]; + deepEqual(doc1.toJSON(), indata.rows[0]); + + // Test UPDATA + var newVal = 10; + doc1.set({x: newVal}); + doc1.save().then(function() { + equal(backend._datasetAsData.data.rows[0].x, newVal); + }) + + // Test Delete + doc1.destroy().then(function() { + equal(backend._datasetAsData.data.rows.length, 5); + equal(backend._datasetAsData.data.rows[0].x, indata.rows[1].x); }); + }); }); }); @@ -120,30 +134,34 @@ webstoreData = { }; test('Webstore Backend', function() { - stop(); - var backend = new recline.BackendWebstore({ + var backend = new recline.Model.BackendWebstore({ url: 'http://webstore.test.ckan.org/rufuspollock/demo/data' }); - recline.setBackend(backend); + recline.Model.setBackend(backend); dataset = backend.getDataset(); var stub = sinon.stub($, 'ajax', function(options) { - return { - then: function(callback) { - callback(webstoreSchema); + if (options.url.indexOf('schema.json') != -1) { + return { + then: function(callback) { + callback(webstoreSchema); + } + } + } else { + return { + then: function(callback) { + callback(webstoreData); + } } } }); dataset.fetch().then(function(dataset) { - equal(['__id__', 'date', 'geometry', 'amount'], dataset.get('headers')); + deepEqual(['__id__', 'date', 'geometry', 'amount'], dataset.get('headers')); equal(3, dataset.rowCount) - // restore mocked method - $.ajax.restore(); - dataset.getRows().then(function(rows) { - start(); - equal(3,rows.length) - equal("2009-01-01", rows[0].date); + dataset.getRows().then(function(docList) { + equal(3, docList.length) + equal("2009-01-01", docList.models[0].get('date')); }); }); }); diff --git a/test/view.test.js b/test/view.test.js new file mode 100644 index 00000000..e8eb9755 --- /dev/null +++ b/test/view.test.js @@ -0,0 +1,25 @@ +(function ($) { + +module("View"); + +test('new DataTableRow View', function () { + var $el = $(''); + $('.fixtures .test-datatable').append($el); + var doc = new recline.Model.Document({ + 'id': 1, + 'b': '2', + 'a': '1' + }); + var view = new recline.View.DataTableRow({ + model: doc + , el: $el + , headers: ['a', 'b'] + }); + view.render(); + ok($el.attr('data-id'), '1'); + var tds = $el.find('td'); + equal(tds.length, 3); + equal($(tds[1]).attr('data-header'), 'a'); +}); + +})(this.jQuery); diff --git a/vendor/000-1.6.1.min.js b/vendor/000-jquery-1.6.1.min.js similarity index 100% rename from vendor/000-1.6.1.min.js rename to vendor/000-jquery-1.6.1.min.js