diff --git a/README.md b/README.md index 46756d19..6db0a2e9 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Live demo: http://okfnlabs.org/recline/demo/ * Bulk update/clean your data using an easy scripting UI * Visualize your data -![screenshot](http://i.imgur.com/XDSRe.png) +![screenshot](http://farm8.staticflickr.com/7020/6847468031_0f474de5f7_b.jpg) ## Demo App diff --git a/css/data-explorer.css b/css/data-explorer.css index c6683954..7ed4e66a 100644 --- a/css/data-explorer.css +++ b/css/data-explorer.css @@ -150,7 +150,7 @@ * Data Table Menus *********************************************************/ -a.column-header-menu { +a.column-header-menu, a.root-header-menu { float: right; display: block; margin: 0 4px 0 0; @@ -160,7 +160,7 @@ a.column-header-menu { background-repeat: no-repeat; } -a.row-header-menu:hover { +a.row-header-menu:hover, a.root-header-menu:hover { background-position: -17px 0px; text-decoration: none; } @@ -175,6 +175,10 @@ a.row-header-menu { background-repeat: no-repeat; } +.read-only a.row-header-menu { + display: none; +} + a.column-header-menu:hover { background-position: -17px 0px; text-decoration: none; @@ -511,14 +515,14 @@ td.expression-preview-value { * Read-only mode *********************************************************/ -.read-only .data-table tr td:first-child, +/*.read-only .data-table tr td:first-child, .read-only .data-table tr th:first-child { display: none; } +*/ -.read-only .column-header-menu, -.read-only .row-header-menu, +.read-only .write-op, .read-only a.data-table-cell-edit { display: none; diff --git a/index.html b/index.html index 7306183c..f2bc0da4 100644 --- a/index.html +++ b/index.html @@ -84,15 +84,24 @@
  • Demo
  • +

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

    +

    Development Version (v0.2)

    + +

    Using It

    +

    Check out the the Demo and view source. The + javascript you want for actual integration is in: app.js.

    +

    Docs

    -

    Want to see how to embed this in your own application. Check out the the - Demo and view source.

    +

    Tests

    +

    Run the tests online.

    +

    History

    Max Ogden was developing Recline as the frontend data browser and editor for his http://datacouch.com/ project. diff --git a/recline.js b/recline.js new file mode 100644 index 00000000..51a91b0e --- /dev/null +++ b/recline.js @@ -0,0 +1,1680 @@ +// # Recline Backends +// +// Backends are connectors to backend data sources and stores +// +// Backends are implemented as Backbone models but this is just a +// convenience (they do not save or load themselves from any remote +// source) +this.recline = this.recline || {}; +this.recline.Model = this.recline.Model || {}; + +(function($, my) { + my.backends = {}; + + // ## Backbone.sync + // + // Override Backbone.sync to hand off to sync function in relevant backend + Backbone.sync = function(method, model, options) { + return my.backends[model.backendConfig.type].sync(method, model, options); + } + + // ## wrapInTimeout + // + // Crude way to catch backend errors + // Many of backends use JSONP and so will not get error messages and this is + // a crude way to catch those errors. + function wrapInTimeout(ourFunction) { + var dfd = $.Deferred(); + var timeout = 5000; + var timer = setTimeout(function() { + dfd.reject({ + message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' + }); + }, timeout); + ourFunction.done(function(arguments) { + clearTimeout(timer); + dfd.resolve(arguments); + }) + .fail(function(arguments) { + clearTimeout(timer); + dfd.reject(arguments); + }) + ; + return dfd.promise(); + } + + // ## BackendMemory - uses in-memory data + // + // To use you should: + // + // A. provide metadata as model data to the Dataset + // + // B. Set backendConfig on your dataset with attributes: + // + // - type: 'memory' + // - data: hash with 2 keys: + // + // * headers: list of header names/labels + // * rows: list of hashes, each hash being one row. A row *must* have an id attribute which is unique. + // + // Example of data: + // + //

    +  //        {
    +  //            headers: ['x', 'y', 'z']
    +  //          , rows: [
    +  //              {id: 0, x: 1, y: 2, z: 3}
    +  //            , {id: 1, x: 2, y: 4, z: 6}
    +  //          ]
    +  //        };
    +  //  
    + my.BackendMemory = Backbone.Model.extend({ + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + if (model.__type__ == 'Dataset') { + var dataset = model; + dataset.set({ + headers: dataset.backendConfig.data.headers + }); + dataset.docCount = dataset.backendConfig.data.rows.length; + dfd.resolve(dataset); + } + return dfd.promise(); + } else if (method === 'update') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + _.each(model.backendConfig.data.rows, function(row, idx) { + if(row.id === model.id) { + model.backendConfig.data.rows[idx] = model.toJSON(); + } + }); + dfd.resolve(model); + } + return dfd.promise(); + } else if (method === 'delete') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + model.backendConfig.data.rows = _.reject(model.backendConfig.data.rows, function(row) { + return (row.id === model.id); + }); + dfd.resolve(model); + } + return dfd.promise(); + } else { + alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); + } + }, + query: function(model, queryObj) { + var numRows = queryObj.size; + var start = queryObj.offset; + var dfd = $.Deferred(); + results = model.backendConfig.data.rows; + // not complete sorting! + _.each(queryObj.sort, function(item) { + results = _.sortBy(results, function(row) { + var _out = row[item[0]]; + return (item[1] == 'asc') ? _out : -1*_out; + }); + }); + var results = results.slice(start, start+numRows); + dfd.resolve(results); + return dfd.promise(); + } + }); + my.backends['memory'] = new my.BackendMemory(); + + // ## BackendWebstore + // + // Connecting to [Webstores](http://github.com/okfn/webstore) + // + // To use this backend set backendConfig on your Dataset as: + // + //
    +  // {
    +  //   'type': 'webstore',
    +  //   'url': url to relevant Webstore table
    +  // }
    +  // 
    + my.BackendWebstore = Backbone.Model.extend({ + sync: function(method, model, options) { + if (method === "read") { + if (model.__type__ == 'Dataset') { + var dataset = model; + var base = dataset.backendConfig.url; + var schemaUrl = base + '/schema.json'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp', + jsonp: '_callback' + }); + var dfd = $.Deferred(); + wrapInTimeout(jqxhr).done(function(schema) { + headers = _.map(schema.data, function(item) { + return item.name; + }); + dataset.set({ + headers: headers + }); + dataset.docCount = schema.count; + dfd.resolve(dataset, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } + }, + query: function(model, queryObj) { + var base = model.backendConfig.url; + var data = { + _limit: queryObj.size + , _offset: queryObj.offset + }; + var jqxhr = $.ajax({ + url: base + '.json', + data: data, + dataType: 'jsonp', + jsonp: '_callback', + cache: true + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + dfd.resolve(results.data); + }); + return dfd.promise(); + } + }); + my.backends['webstore'] = new my.BackendWebstore(); + + // ## BackendDataProxy + // + // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). + // + // Set a Dataset to use this backend: + // + // dataset.backendConfig = { + // // required + // url: {url-of-data-to-proxy}, + // format: csv | xls, + // } + // + // When initializing the DataProxy backend you can set the following attributes: + // + // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com + // + // Note that this is a **read-only** backend. + my.BackendDataProxy = Backbone.Model.extend({ + defaults: { + dataproxy: 'http://jsonpdataproxy.appspot.com' + }, + sync: function(method, model, options) { + if (method === "read") { + if (model.__type__ == 'Dataset') { + var dataset = model; + var base = my.backends['dataproxy'].get('dataproxy'); + // TODO: should we cache for extra efficiency + var data = { + url: dataset.backendConfig.url + , 'max-results': 1 + , type: dataset.backendConfig.format + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + wrapInTimeout(jqxhr).done(function(results) { + dataset.set({ + headers: results.fields + }); + dfd.resolve(dataset, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + } + } else { + alert('This backend only supports read operations'); + } + }, + query: function(dataset, queryObj) { + var base = my.backends['dataproxy'].get('dataproxy'); + var data = { + url: dataset.backendConfig.url + , 'max-results': queryObj.size + , type: dataset.backendConfig.format + }; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + jqxhr.done(function(results) { + var _out = _.map(results.data, function(row) { + var tmp = {}; + _.each(results.fields, function(key, idx) { + tmp[key] = row[idx]; + }); + return tmp; + }); + dfd.resolve(_out); + }); + return dfd.promise(); + } + }); + my.backends['dataproxy'] = new my.BackendDataProxy(); + + + // ## Google spreadsheet backend + // + // Connect to Google Docs spreadsheet. For write operations + my.BackendGDoc = Backbone.Model.extend({ + sync: function(method, model, options) { + if (method === "read") { + var dfd = $.Deferred(); + var dataset = model; + + $.getJSON(model.backendConfig.url, function(d) { + result = my.backends['gdocs'].gdocsToJavascript(d); + model.set({'headers': result.header}); + // cache data onto dataset (we have loaded whole gdoc it seems!) + model._dataCache = result.data; + dfd.resolve(model); + }) + return dfd.promise(); } + }, + + query: function(dataset, queryObj) { + var dfd = $.Deferred(); + var fields = dataset.get('headers'); + + // zip the field headers with the data rows to produce js objs + // TODO: factor this out as a common method with other backends + var objs = _.map(dataset._dataCache, function (d) { + var obj = {}; + _.each(_.zip(fields, d), function (x) { obj[x[0]] = x[1]; }) + return obj; + }); + dfd.resolve(objs); + return dfd; + }, + gdocsToJavascript: function(gdocsSpreadsheet) { + /* + :options: (optional) optional argument dictionary: + columnsToUse: list of columns to use (specified by header names) + colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). + :return: tabular data object (hash with keys: header and data). + + 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 = { + 'header': [], + 'data': [] + }; + // default is no special info on type of columns + var colTypes = {}; + if (options.colTypes) { + colTypes = options.colTypes; + } + // either extract column headings from spreadsheet directly, or used supplied ones + if (options.columnsToUse) { + // columns set to subset supplied + results.header = options.columnsToUse; + } else { + // set columns to use to be all available + if (gdocsSpreadsheet.feed.entry.length > 0) { + for (var k in gdocsSpreadsheet.feed.entry[0]) { + if (k.substr(0, 3) == 'gsx') { + var col = k.substr(4) + results.header.push(col); + } + } + } + } + + // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) + var rep = /^([\d\.\-]+)\%$/; + $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { + var row = []; + for (var k in results.header) { + var col = results.header[k]; + var _keyname = 'gsx$' + col; + var value = entry[_keyname]['$t']; + // if labelled as % and value contains %, convert + if (colTypes[col] == 'percent') { + if (rep.test(value)) { + var value2 = rep.exec(value); + var value3 = parseFloat(value2); + value = value3 / 100; + } + } + row.push(value); + } + results.data.push(row); + }); + return results; + } + }); + my.backends['gdocs'] = new my.BackendGDoc(); + +}(jQuery, this.recline.Model)); +// importScripts('lib/underscore.js'); + +onmessage = function(message) { + + function parseCSV(rawCSV) { + var patterns = new RegExp(( + // Delimiters. + "(\\,|\\r?\\n|\\r|^)" + + // Quoted fields. + "(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + + // Standard fields. + "([^\"\\,\\r\\n]*))" + ), "gi"); + + var rows = [[]], matches = null; + + while (matches = patterns.exec(rawCSV)) { + var delimiter = matches[1]; + + if (delimiter.length && (delimiter !== ",")) rows.push([]); + + if (matches[2]) { + var value = matches[2].replace(new RegExp("\"\"", "g"), "\""); + } else { + var value = matches[3]; + } + rows[rows.length - 1].push(value); + } + + if(_.isEqual(rows[rows.length -1], [""])) rows.pop(); + + var docs = []; + var headers = _.first(rows); + _.each(_.rest(rows), function(row, rowIDX) { + var doc = {}; + _.each(row, function(cell, idx) { + doc[headers[idx]] = cell; + }) + docs.push(doc); + }) + + return docs; + } + + var docs = parseCSV(message.data.data); + + var req = new XMLHttpRequest(); + + req.onprogress = req.upload.onprogress = function(e) { + if(e.lengthComputable) postMessage({ percent: (e.loaded / e.total) * 100 }); + }; + + req.onreadystatechange = function() { if (req.readyState == 4) postMessage({done: true, response: req.responseText}) }; + req.open('POST', message.data.url); + req.setRequestHeader('Content-Type', 'application/json'); + req.send(JSON.stringify({docs: docs})); +}; +// adapted from https://github.com/harthur/costco. heather rules + +var costco = function() { + + function evalFunction(funcString) { + try { + eval("var editFunc = " + funcString); + } catch(e) { + return {errorMessage: e+""}; + } + return editFunc; + } + + function previewTransform(docs, editFunc, currentColumn) { + var preview = []; + var updated = mapDocs($.extend(true, {}, docs), editFunc); + for (var i = 0; i < updated.docs.length; i++) { + var before = docs[i] + , after = updated.docs[i] + ; + if (!after) after = {}; + if (currentColumn) { + preview.push({before: JSON.stringify(before[currentColumn]), after: JSON.stringify(after[currentColumn])}); + } else { + preview.push({before: JSON.stringify(before), after: JSON.stringify(after)}); + } + } + return preview; + } + + function mapDocs(docs, editFunc) { + var edited = [] + , deleted = [] + , failed = [] + ; + + var updatedDocs = _.map(docs, function(doc) { + try { + var updated = editFunc(_.clone(doc)); + } catch(e) { + failed.push(doc); + return; + } + if(updated === null) { + updated = {_deleted: true}; + edited.push(updated); + deleted.push(doc); + } + else if(updated && !_.isEqual(updated, doc)) { + edited.push(updated); + } + return updated; + }); + + return { + edited: edited, + docs: updatedDocs, + deleted: deleted, + failed: failed + }; + } + + return { + evalFunction: evalFunction, + previewTransform: previewTransform, + mapDocs: mapDocs + }; +}(); +// # Recline Backbone Models +this.recline = this.recline || {}; +this.recline.Model = this.recline.Model || {}; + +(function($, my) { + // ## A Dataset model + // + // Other than standard list of Backbone methods it has two important attributes: + // + // * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) + // * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) + my.Dataset = Backbone.Model.extend({ + __type__: 'Dataset', + initialize: function(options) { + this.currentDocuments = new my.DocumentList(); + this.docCount = null; + this.backend = null; + this.defaultQuery = { + size: 100 + , offset: 0 + }; + // this.queryState = {}; + }, + + // ### getDocuments + // + // AJAX method with promise API to get rows (documents) from the backend. + // + // Resulting DocumentList are used to reset this.currentDocuments and are + // also returned. + // + // :param numRows: passed onto backend getDocuments. + // :param start: passed onto backend getDocuments. + // + // this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. + // This also illustrates the limitations of separating the Dataset and the Backend + query: function(queryObj) { + var self = this; + var backend = my.backends[this.backendConfig.type]; + this.queryState = queryObj || this.defaultQuery; + this.queryState = _.extend({size: 100, offset: 0}, this.queryState); + var dfd = $.Deferred(); + backend.query(this, this.queryState).done(function(rows) { + var docs = _.map(rows, function(row) { + var _doc = new my.Document(row); + _doc.backendConfig = self.backendConfig; + _doc.backend = backend; + return _doc; + }); + self.currentDocuments.reset(docs); + dfd.resolve(self.currentDocuments); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }, + + toTemplateJSON: function() { + var data = this.toJSON(); + data.docCount = this.docCount; + return data; + } + }); + + // ## A Document (aka Row) + // + // A single entry or row in the dataset + my.Document = Backbone.Model.extend({ + __type__: 'Document' + }); + + // ## A Backbone collection of Documents + my.DocumentList = Backbone.Collection.extend({ + __type__: 'DocumentList', + model: my.Document + }); +}(jQuery, this.recline.Model)); + +var util = function() { + var templates = { + transformActions: '
  • Global transform...
  • ' + , columnActions: ' \ +
  • Transform...
  • \ +
  • Delete this column
  • \ +
  • Sort ascending
  • \ +
  • Sort descending
  • \ +
  • Hide this column
  • \ + ' + , rowActions: '
  • Delete this row
  • ' + , rootActions: ' \ + {{#columns}} \ +
  • Add column: {{.}}
  • \ + {{/columns}}' + , cellEditor: ' \ + \ + ' + , editPreview: ' \ +
    \ + \ + \ + \ + \ + \ + \ + \ + \ + {{#rows}} \ + \ + \ + \ + \ + {{/rows}} \ + \ +
    \ + before \ + \ + after \ +
    \ + {{before}} \ + \ + {{after}} \ +
    \ +
    \ + ' + }; + + $.fn.serializeObject = function() { + var o = {}; + var a = this.serializeArray(); + $.each(a, function() { + if (o[this.name]) { + if (!o[this.name].push) { + o[this.name] = [o[this.name]]; + } + o[this.name].push(this.value || ''); + } else { + o[this.name] = this.value || ''; + } + }); + return o; + }; + + function registerEmitter() { + var Emitter = function(obj) { + this.emit = function(obj, channel) { + if (!channel) var channel = 'data'; + this.trigger(channel, obj); + }; + }; + MicroEvent.mixin(Emitter); + return new Emitter(); + } + + function listenFor(keys) { + var shortcuts = { // from jquery.hotkeys.js + 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", + 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", + 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del", + 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", + 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8", + 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 191: "/", 224: "meta" + } + window.addEventListener("keyup", function(e) { + var pressed = shortcuts[e.keyCode]; + if(_.include(keys, pressed)) app.emitter.emit("keyup", pressed); + }, false); + } + + function observeExit(elem, callback) { + var cancelButton = elem.find('.cancelButton'); + // TODO: remove (commented out as part of Backbon-i-fication + // app.emitter.on('esc', function() { + // cancelButton.click(); + // app.emitter.clear('esc'); + // }); + cancelButton.click(callback); + } + + function show( thing ) { + $('.' + thing ).show(); + $('.' + thing + '-overlay').show(); + } + + function hide( thing ) { + $('.' + thing ).hide(); + $('.' + thing + '-overlay').hide(); + // TODO: remove or replace (commented out as part of Backbon-i-fication + // if (thing === "dialog") app.emitter.clear('esc'); // todo more elegant solution + } + + function position( thing, elem, offset ) { + var position = $(elem.target).position(); + if (offset) { + if (offset.top) position.top += offset.top; + if (offset.left) position.left += offset.left; + } + $('.' + thing + '-overlay').show().click(function(e) { + $(e.target).hide(); + $('.' + thing).hide(); + }); + $('.' + thing).show().css({top: position.top + $(elem.target).height(), left: position.left}); + } + + function render( template, target, options ) { + if ( !options ) options = {data: {}}; + if ( !options.data ) options = {data: options}; + var html = $.mustache( templates[template], options.data ); + if (target instanceof jQuery) { + var targetDom = target; + } else { + var targetDom = $( "." + target + ":first" ); + } + if( options.append ) { + targetDom.append( html ); + } else { + targetDom.html( html ); + } + // TODO: remove (commented out as part of Backbon-i-fication + // if (template in app.after) app.after[template](); + } + + return { + registerEmitter: registerEmitter, + listenFor: listenFor, + show: show, + hide: hide, + position: position, + render: render, + observeExit: observeExit + }; +}(); +this.recline = this.recline || {}; + +// Views module following classic module pattern +recline.View = function($) { + +var my = {}; + +// Parse a URL query string (?xyz=abc...) into a dictionary. +function parseQueryString(q) { + var urlParams = {}, + e, d = function (s) { + return unescape(s.replace(/\+/g, " ")); + }, + r = /([^&=]+)=?([^&]*)/g; + + if (q && q.length && q[0] === '?') { + q = q.slice(1); + } + while (e = r.exec(q)) { + // TODO: have values be array as query string allow repetition of keys + urlParams[d(e[1])] = d(e[2]); + } + return urlParams; +} + +// ## notify +// +// Create a notification (a div.alert-message in div.alert-messsages) using provide messages and options. Options are: +// +// * category: warning (default), success, error +// * persist: if true alert is persistent, o/w hidden after 3s (default = false) +// * loader: if true show loading spinner +my.notify = function(message, options) { + if (!options) var options = {}; + var tmplData = _.extend({ + msg: message, + category: 'warning' + }, + options); + var _template = ' \ +
    × \ +

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

    \ +
    '; + var _templated = $.mustache(_template, tmplData); + _templated = $(_templated).appendTo($('.data-explorer .alert-messages')); + if (!options.persist) { + setTimeout(function() { + $(_templated).fadeOut(1000, function() { + $(this).remove(); + }); + }, 1000); + } +} + +// ## clearNotifications +// +// Clear all existing notifications +my.clearNotifications = function() { + var $notifications = $('.data-explorer .alert-message'); + $notifications.remove(); +} + +// The primary view for the entire application. +// +// It should be initialized with a recline.Model.Dataset object and an existing +// dom element to attach to (the existing DOM element is important for +// rendering of FlotGraph subview). +// +// To pass in configuration options use the config key in initialization hash +// e.g. +// +// var explorer = new DataExplorer({ +// config: {...} +// }) +// +// Config options: +// +// * displayCount: how many documents to display initially (default: 10) +// * readOnly: true/false (default: false) value indicating whether to +// operate in read-only mode (hiding all editing options). +// +// All other views as contained in this one. +my.DataExplorer = Backbone.View.extend({ + template: ' \ +
    \ +
    \ + \ +
    \ + \ + \ +
    \ +
    \ + \ + \ +
    \ + ', + + events: { + 'submit form.display-count': 'onDisplayCountUpdate' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + this.config = _.extend({ + displayCount: 50 + , readOnly: false + }, + options.config); + if (this.config.readOnly) { + this.setReadOnly(); + } + // Hash of 'page' views (i.e. those for whole page) keyed by page name + this.pageViews = { + grid: new my.DataTable({ + model: this.model + }) + , graph: new my.FlotGraph({ + model: this.model + }) + }; + // this must be called after pageViews are created + this.render(); + + this.router = new Backbone.Router(); + this.setupRouting(); + + // retrieve basic data like headers etc + // note this.model and dataset returned are the same + this.model.fetch() + .done(function(dataset) { + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + self.query(); + }) + .fail(function(error) { + my.notify(error.message, {category: 'error', persist: true}); + }); + }, + + query: function() { + this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); + var queryObj = { + size: this.config.displayCount + }; + my.notify('Loading data', {loader: true}); + this.model.query(queryObj) + .done(function() { + my.clearNotifications(); + my.notify('Data loaded', {category: 'success'}); + }) + .fail(function(error) { + my.clearNotifications(); + my.notify(error.message, {category: 'error', persist: true}); + }); + }, + + onDisplayCountUpdate: function(e) { + e.preventDefault(); + this.query(); + }, + + setReadOnly: function() { + this.el.addClass('read-only'); + }, + + render: function() { + var tmplData = this.model.toTemplateJSON(); + tmplData.displayCount = this.config.displayCount; + var template = $.mustache(this.template, tmplData); + $(this.el).html(template); + var $dataViewContainer = this.el.find('.data-view-container'); + _.each(this.pageViews, function(view, pageName) { + $dataViewContainer.append(view.el) + }); + }, + + setupRouting: function() { + var self = this; + this.router.route('', 'grid', function() { + self.updateNav('grid'); + }); + this.router.route(/grid(\?.*)?/, 'view', function(queryString) { + self.updateNav('grid', queryString); + }); + this.router.route(/graph(\?.*)?/, 'graph', function(queryString) { + self.updateNav('graph', queryString); + // we have to call here due to fact plot may not have been able to draw + // if it was hidden until now - see comments in FlotGraph.redraw + qsParsed = parseQueryString(queryString); + if ('graph' in qsParsed) { + var chartConfig = JSON.parse(qsParsed['graph']); + _.extend(self.pageViews['graph'].chartConfig, chartConfig); + } + self.pageViews['graph'].redraw(); + }); + }, + + updateNav: function(pageName, queryString) { + this.el.find('.navigation li').removeClass('active'); + var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); + $el.parent().addClass('active'); + // show the specific page + _.each(this.pageViews, function(view, pageViewName) { + if (pageViewName === pageName) { + view.el.show(); + } else { + view.el.hide(); + } + }); + } +}); + +// DataTable provides a tabular view on a Dataset. +// +// Initialize it with a recline.Dataset object. +my.DataTable = Backbone.View.extend({ + tagName: "div", + className: "data-table-container", + + initialize: function() { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); + this.model.currentDocuments.bind('remove', this.render); + this.state = {}; + this.hiddenHeaders = []; + }, + + events: { + 'click .column-header-menu': 'onColumnHeaderClick' + , 'click .row-header-menu': 'onRowHeaderClick' + , 'click .root-header-menu': 'onRootHeaderClick' + , 'click .data-table-menu li a': 'onMenuClick' + }, + + // TODO: delete or re-enable (currently this code is not used from anywhere except deprecated or disabled methods (see above)). + // showDialog: function(template, data) { + // if (!data) data = {}; + // util.show('dialog'); + // util.render(template, 'dialog-content', data); + // util.observeExit($('.dialog-content'), function() { + // util.hide('dialog'); + // }) + // $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + // }, + + + // ====================================================== + // Column and row menus + + onColumnHeaderClick: function(e) { + this.state.currentColumn = $(e.target).siblings().text(); + util.position('data-table-menu', e); + util.render('columnActions', 'data-table-menu'); + }, + + onRowHeaderClick: function(e) { + this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); + util.position('data-table-menu', e); + util.render('rowActions', 'data-table-menu'); + }, + + onRootHeaderClick: function(e) { + util.position('data-table-menu', e); + util.render('rootActions', 'data-table-menu', {'columns': this.hiddenHeaders}); + }, + + onMenuClick: function(e) { + var self = this; + e.preventDefault(); + var actions = { + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + transform: function() { self.showTransformDialog('transform') }, + sortAsc: function() { self.setColumnSort('asc') }, + sortDesc: function() { self.setColumnSort('desc') }, + hideColumn: function() { self.hideColumn() }, + showColumn: function() { self.showColumn(e) }, + // TODO: Delete or re-implement ... + csv: function() { window.location.href = app.csvUrl }, + json: function() { window.location.href = "_rewrite/api/json" }, + urlImport: function() { showDialog('urlImport') }, + pasteImport: function() { showDialog('pasteImport') }, + uploadImport: function() { showDialog('uploadImport') }, + // END TODO + deleteColumn: function() { + var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents."; + // TODO: + alert('This function needs to be re-implemented'); + return; + if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); + }, + deleteRow: function() { + var doc = _.find(self.model.currentDocuments.models, function(doc) { + // important this is == as the currentRow will be string (as comes + // from DOM) while id may be int + return doc.id == self.state.currentRow + }); + doc.destroy().then(function() { + self.model.currentDocuments.remove(doc); + my.notify("Row deleted successfully"); + }) + .fail(function(err) { + my.notify("Errorz! " + err) + }) + } + } + util.hide('data-table-menu'); + actions[$(e.target).attr('data-action')](); + }, + + showTransformColumnDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.ColumnTransform({ + model: this.model + }); + view.state = this.state; + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + showTransformDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.DataTransform({ + }); + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + setColumnSort: function(order) { + var query = _.extend(this.model.queryState, {sort: [[this.state.currentColumn, order]]}); + this.model.query(query); + }, + + hideColumn: function() { + this.hiddenHeaders.push(this.state.currentColumn); + this.render(); + }, + + showColumn: function(e) { + this.hiddenHeaders = _.without(this.hiddenHeaders, $(e.target).data('column')); + this.render(); + }, + + // ====================================================== + // Core Templating + template: ' \ + \ + \ + \ + \ + \ + {{#notEmpty}} \ + \ + {{/notEmpty}} \ + {{#headers}} \ + \ + {{/headers}} \ + \ + \ + \ +
    \ +
    \ + \ + \ +
    \ +
    \ +
    \ + \ + {{.}} \ +
    \ + \ +
    \ + ', + + toTemplateJSON: function() { + var modelData = this.model.toJSON() + modelData.notEmpty = ( this.headers.length > 0 ) + modelData.headers = this.headers; + return modelData; + }, + render: function() { + var self = this; + this.headers = _.filter(this.model.get('headers'), function(header) { + return _.indexOf(self.hiddenHeaders, header) == -1; + }); + var htmls = $.mustache(this.template, this.toTemplateJSON()); + this.el.html(htmls); + this.model.currentDocuments.forEach(function(doc) { + var tr = $(''); + self.el.find('tbody').append(tr); + var newView = new my.DataTableRow({ + model: doc, + el: tr, + headers: self.headers, + }); + newView.render(); + }); + $(".root-header-menu").toggle((self.hiddenHeaders.length > 0)); + return this; + } +}); + +// DataTableRow View for rendering an individual document. +// +// Since we want this to update in place it is up to creator to provider the element to attach to. +// In addition you must pass in a headers in the constructor options. This should be list of headers for the DataTable. +my.DataTableRow = Backbone.View.extend({ + initialize: function(options) { + _.bindAll(this, 'render'); + this._headers = options.headers; + this.el = $(this.el); + this.model.bind('change', this.render); + }, + + template: ' \ + \ + {{#cells}} \ + \ +
    \ +   \ +
    {{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 + + onEditClick: function(e) { + var editing = this.el.find('.data-table-cell-editor-editor'); + if (editing.length > 0) { + editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden"); + } + $(e.target).addClass("hidden"); + var cell = $(e.target).siblings('.data-table-cell-value'); + cell.data("previousContents", cell.text()); + util.render('cellEditor', cell, {value: cell.text()}); + }, + + onEditorOK: function(e) { + var cell = $(e.target); + var rowId = cell.parents('tr').attr('data-id'); + var header = cell.parents('td').attr('data-header'); + var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); + var newData = {}; + newData[header] = newValue; + this.model.set(newData); + my.notify("Updating row...", {loader: true}); + this.model.save().then(function(response) { + my.notify("Row updated successfully", {category: 'success'}); + }) + .fail(function() { + my.notify('Error saving row', { + category: 'error', + persist: true + }); + }); + }, + + onEditorCancel: function(e) { + var cell = $(e.target).parents('.data-table-cell-value'); + cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); + } +}); + + +// 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) { + my.notify("Error with function! " + editFunc.errorMessage); + return; + } + util.hide('dialog'); + my.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) { + my.notify(toUpdate.length + " documents updated successfully"); + alert('WARNING: We have only updated the docs in this view. (Updating of all docs not yet implemented!)'); + self.remove(); + } + } + // TODO: Very inefficient as we search through all docs every time! + _.each(toUpdate, function(editedDoc) { + var realDoc = self.model.currentDocuments.get(editedDoc.id); + realDoc.set(editedDoc); + realDoc.save().then(onCompletedUpdate).fail(onCompletedUpdate) + }); + }, + + onEditorKeydown: function(e) { + var self = this; + // if you don't setTimeout it won't grab the latest character if you call e.target.value + window.setTimeout( function() { + var errors = self.el.find('.expression-preview-parsing-status'); + var editFunc = costco.evalFunction(e.target.value); + if (!editFunc.errorMessage) { + errors.text('No syntax error.'); + var docs = self.model.currentDocuments.map(function(doc) { + return doc.toJSON(); + }); + var previewData = costco.previewTransform(docs, editFunc, self.state.currentColumn); + util.render('editPreview', 'expression-preview-container', {rows: previewData}); + } else { + errors.text(editFunc.errorMessage); + } + }, 1, true); + } +}); + +// View (Dialog) for doing data transformations on whole dataset. +my.DataTransform = Backbone.View.extend({ + className: 'transform-view', + template: ' \ +
    \ + Recursive transform on all rows \ +
    \ +
    \ +
    \ +

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

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

    Help »

    \ +

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

    \ +

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

    \ +
    \ +
    \ +
    \ + \ +
    \ + \ +
    \ + \ +
    \ + \ +
    \ +
    \ +
    \ + \ +
    \ + \ +
    \ +
    \ +
    \ +
    \ +
    \ + \ +
    \ + \ +
    \ +
    \ +
    \ + \ +', + + events: { + 'change form select': 'onEditorSubmit' + , 'click .editor-add': 'addSeries' + , 'click .action-remove-series': 'removeSeries' + , 'click .action-toggle-help': 'toggleHelp' + }, + + initialize: function(options, config) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render', 'redraw'); + // we need the model.headers to render properly + this.model.bind('change', this.render); + this.model.currentDocuments.bind('add', this.redraw); + this.model.currentDocuments.bind('reset', this.redraw); + this.chartConfig = _.extend({ + group: null, + series: [], + graphType: 'line' + }, + config) + this.render(); + }, + + toTemplateJSON: function() { + return this.model.toJSON(); + }, + + render: function() { + htmls = $.mustache(this.template, this.toTemplateJSON()); + $(this.el).html(htmls); + // now set a load of stuff up + this.$graph = this.el.find('.panel.graph'); + // for use later when adding additional series + // could be simpler just to have a common template! + this.$seriesClone = this.el.find('.editor-series').clone(); + this._updateSeries(); + return this; + }, + + onEditorSubmit: function(e) { + var select = this.el.find('.editor-group select'); + this._getEditorData(); + // update navigation + // TODO: make this less invasive (e.g. preserve other keys in query string) + window.location.hash = window.location.hash.split('?')[0] + + '?graph=' + JSON.stringify(this.chartConfig); + this.redraw(); + }, + + redraw: function() { + // There appear to be issues generating a Flot graph if either: + + // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with + // + // Uncaught Invalid dimensions for plot, width = 0, height = 0 + // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' + var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); + if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { + return + } + // create this.plot and cache it + if (!this.plot) { + // only lines for the present + options = { + id: 'line', + name: 'Line Chart' + }; + this.plot = $.plot(this.$graph, this.createSeries(), options); + } + this.plot.setData(this.createSeries()); + this.plot.resize(); + this.plot.setupGrid(); + this.plot.draw(); + }, + + _getEditorData: function() { + $editor = this + var series = this.$series.map(function () { + return $(this).val(); + }); + this.chartConfig.series = $.makeArray(series) + this.chartConfig.group = this.el.find('.editor-group select').val(); + }, + + createSeries: function () { + var self = this; + var series = []; + if (this.chartConfig) { + $.each(this.chartConfig.series, function (seriesIndex, field) { + var points = []; + $.each(self.model.currentDocuments.models, function (index, doc) { + var x = doc.get(self.chartConfig.group); + var y = doc.get(field); + if (typeof x === 'string') { + x = index; + } + points.push([x, y]); + }); + series.push({data: points, label: field}); + }); + } + return series; + }, + + // Public: Adds a new empty series select box to the editor. + // + // All but the first select box will have a remove button that allows them + // to be removed. + // + // Returns itself. + addSeries: function (e) { + e.preventDefault(); + var element = this.$seriesClone.clone(), + label = element.find('label'), + index = this.$series.length; + + this.el.find('.editor-series-group').append(element); + this._updateSeries(); + label.append(' [Remove]'); + label.find('span').text(String.fromCharCode(this.$series.length + 64)); + return this; + }, + + // Public: Removes a series list item from the editor. + // + // Also updates the labels of the remaining series elements. + removeSeries: function (e) { + e.preventDefault(); + var $el = $(e.target); + $el.parent().parent().remove(); + this._updateSeries(); + this.$series.each(function (index) { + if (index > 0) { + var labelSpan = $(this).prev().find('span'); + labelSpan.text(String.fromCharCode(index + 65)); + } + }); + this.onEditorSubmit(); + }, + + toggleHelp: function() { + this.el.find('.editor-info').toggleClass('editor-hide-info'); + }, + + // Private: Resets the series property to reference the select elements. + // + // Returns itself. + _updateSeries: function () { + this.$series = this.el.find('.editor-series select'); + } +}); + +return my; + +}(jQuery); + diff --git a/src/backend.js b/src/backend.js index 7fae84b6..ae32508e 100644 --- a/src/backend.js +++ b/src/backend.js @@ -11,10 +11,38 @@ this.recline.Model = this.recline.Model || {}; (function($, my) { my.backends = {}; + // ## Backbone.sync + // + // Override Backbone.sync to hand off to sync function in relevant backend Backbone.sync = function(method, model, options) { return my.backends[model.backendConfig.type].sync(method, model, options); } + // ## wrapInTimeout + // + // Crude way to catch backend errors + // Many of backends use JSONP and so will not get error messages and this is + // a crude way to catch those errors. + function wrapInTimeout(ourFunction) { + var dfd = $.Deferred(); + var timeout = 5000; + var timer = setTimeout(function() { + dfd.reject({ + message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' + }); + }, timeout); + ourFunction.done(function(arguments) { + clearTimeout(timer); + dfd.resolve(arguments); + }) + .fail(function(arguments) { + clearTimeout(timer); + dfd.reject(arguments); + }) + ; + return dfd.promise(); + } + // ## BackendMemory - uses in-memory data // // To use you should: @@ -78,16 +106,19 @@ this.recline.Model = this.recline.Model || {}; alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); } }, - getDocuments: function(model, numRows, start) { - if (start === undefined) { - start = 0; - } - if (numRows === undefined) { - numRows = 10; - } + query: function(model, queryObj) { + var numRows = queryObj.size; + var start = queryObj.offset; var dfd = $.Deferred(); - rows = model.backendConfig.data.rows; - var results = rows.slice(start, start+numRows); + results = model.backendConfig.data.rows; + // not complete sorting! + _.each(queryObj.sort, function(item) { + results = _.sortBy(results, function(row) { + var _out = row[item[0]]; + return (item[1] == 'asc') ? _out : -1*_out; + }); + }); + var results = results.slice(start, start+numRows); dfd.resolve(results); return dfd.promise(); } @@ -119,7 +150,7 @@ this.recline.Model = this.recline.Model || {}; jsonp: '_callback' }); var dfd = $.Deferred(); - jqxhr.then(function(schema) { + wrapInTimeout(jqxhr).done(function(schema) { headers = _.map(schema.data, function(item) { return item.name; }); @@ -128,27 +159,29 @@ this.recline.Model = this.recline.Model || {}; }); dataset.docCount = schema.count; dfd.resolve(dataset, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); }); return dfd.promise(); } } }, - getDocuments: function(model, numRows, start) { - if (start === undefined) { - start = 0; - } - if (numRows === undefined) { - numRows = 10; - } + query: function(model, queryObj) { var base = model.backendConfig.url; + var data = { + _limit: queryObj.size + , _offset: queryObj.offset + }; var jqxhr = $.ajax({ - url: base + '.json?_limit=' + numRows, - dataType: 'jsonp', - jsonp: '_callback', - cache: true + url: base + '.json', + data: data, + dataType: 'jsonp', + jsonp: '_callback', + cache: true }); var dfd = $.Deferred(); - jqxhr.then(function(results) { + jqxhr.done(function(results) { dfd.resolve(results.data); }); return dfd.promise(); @@ -194,11 +227,14 @@ this.recline.Model = this.recline.Model || {}; , dataType: 'jsonp' }); var dfd = $.Deferred(); - jqxhr.then(function(results) { + wrapInTimeout(jqxhr).done(function(results) { dataset.set({ headers: results.fields }); dfd.resolve(dataset, jqxhr); + }) + .fail(function(arguments) { + dfd.reject(arguments); }); return dfd.promise(); } @@ -206,17 +242,11 @@ this.recline.Model = this.recline.Model || {}; alert('This backend only supports read operations'); } }, - getDocuments: function(dataset, numRows, start) { - if (start === undefined) { - start = 0; - } - if (numRows === undefined) { - numRows = 10; - } + query: function(dataset, queryObj) { var base = my.backends['dataproxy'].get('dataproxy'); var data = { url: dataset.backendConfig.url - , 'max-results': numRows + , 'max-results': queryObj.size , type: dataset.backendConfig.format }; var jqxhr = $.ajax({ @@ -225,7 +255,7 @@ this.recline.Model = this.recline.Model || {}; , dataType: 'jsonp' }); var dfd = $.Deferred(); - jqxhr.then(function(results) { + jqxhr.done(function(results) { var _out = _.map(results.data, function(row) { var tmp = {}; _.each(results.fields, function(key, idx) { @@ -260,7 +290,7 @@ this.recline.Model = this.recline.Model || {}; return dfd.promise(); } }, - getDocuments: function(dataset, start, numRows) { + query: function(dataset, queryObj) { var dfd = $.Deferred(); var fields = dataset.get('headers'); diff --git a/src/costco.js b/src/costco.js index 2256b3c3..86754390 100644 --- a/src/costco.js +++ b/src/costco.js @@ -60,110 +60,9 @@ var costco = function() { }; } - function updateDocs(editFunc) { - var dfd = $.Deferred(); - util.notify("Download entire database into Recline. This could take a while...", {persist: true, loader: true}); - couch.request({url: app.baseURL + "api/json"}).then(function(docs) { - util.notify("Updating " + docs.docs.length + " documents. This could take a while...", {persist: true, loader: true}); - var toUpdate = costco.mapDocs(docs.docs, editFunc).edited; - costco.uploadDocs(toUpdate).then( - function(updatedDocs) { - util.notify(updatedDocs.length + " documents updated successfully"); - recline.initializeTable(app.offset); - dfd.resolve(updatedDocs); - }, - function(err) { - util.notify("Errorz! " + err); - dfd.reject(err); - } - ); - }); - return dfd.promise(); - } - - function updateDoc(doc) { - return couch.request({type: "PUT", url: app.baseURL + "api/" + doc._id, data: JSON.stringify(doc)}) - } - - function uploadDocs(docs) { - var dfd = $.Deferred(); - if(!docs.length) dfd.resolve("Failed: No docs specified"); - couch.request({url: app.baseURL + "api/_bulk_docs", type: "POST", data: JSON.stringify({docs: docs})}) - .then( - function(resp) {ensureCommit().then(function() { - var error = couch.responseError(resp); - if (error) { - dfd.reject(error); - } else { - dfd.resolve(resp); - } - })}, - function(err) { dfd.reject(err.responseText) } - ); - return dfd.promise(); - } - - function ensureCommit() { - return couch.request({url: app.baseURL + "api/_ensure_full_commit", type:'POST', data: "''"}); - } - - function deleteColumn(name) { - var deleteFunc = function(doc) { - delete doc[name]; - return doc; - } - return updateDocs(deleteFunc); - } - - function uploadCSV() { - var file = $('#file')[0].files[0]; - if (file) { - var reader = new FileReader(); - reader.readAsText(file); - reader.onload = function(event) { - var payload = { - url: window.location.href + "/api/_bulk_docs", // todo more robust url composition - data: event.target.result - }; - var worker = new Worker('script/costco-csv-worker.js'); - worker.onmessage = function(event) { - var message = event.data; - if (message.done) { - var error = couch.responseError(JSON.parse(message.response)) - console.log('e',error) - if (error) { - app.emitter.emit(error, 'error'); - } else { - util.notify("Data uploaded successfully!"); - recline.initializeTable(app.offset); - } - util.hide('dialog'); - } else if (message.percent) { - if (message.percent === 100) { - util.notify("Waiting for CouchDB...", {persist: true, loader: true}) - } else { - util.notify("Uploading... " + message.percent + "%"); - } - } else { - util.notify(JSON.stringify(message)); - } - }; - worker.postMessage(payload); - }; - } else { - util.notify('File not selected. Please try again'); - } - }; - return { evalFunction: evalFunction, previewTransform: previewTransform, - mapDocs: mapDocs, - updateDocs: updateDocs, - updateDoc: updateDoc, - uploadDocs: uploadDocs, - deleteColumn: deleteColumn, - ensureCommit: ensureCommit, - uploadCSV: uploadCSV + mapDocs: mapDocs }; }(); diff --git a/src/model.js b/src/model.js index 9eccc2f1..36d6da5e 100644 --- a/src/model.js +++ b/src/model.js @@ -15,6 +15,11 @@ this.recline.Model = this.recline.Model || {}; this.currentDocuments = new my.DocumentList(); this.docCount = null; this.backend = null; + this.defaultQuery = { + size: 100 + , offset: 0 + }; + // this.queryState = {}; }, // ### getDocuments @@ -29,11 +34,13 @@ this.recline.Model = this.recline.Model || {}; // // 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 - getDocuments: function(numRows, start) { + query: function(queryObj) { var self = this; var backend = my.backends[this.backendConfig.type]; + this.queryState = queryObj || this.defaultQuery; + this.queryState = _.extend({size: 100, offset: 0}, this.queryState); var dfd = $.Deferred(); - backend.getDocuments(this, numRows, start).then(function(rows) { + backend.query(this, this.queryState).done(function(rows) { var docs = _.map(rows, function(row) { var _doc = new my.Document(row); _doc.backendConfig = self.backendConfig; @@ -42,6 +49,9 @@ this.recline.Model = this.recline.Model || {}; }); self.currentDocuments.reset(docs); dfd.resolve(self.currentDocuments); + }) + .fail(function(arguments) { + dfd.reject(arguments); }); return dfd.promise(); }, diff --git a/src/util.js b/src/util.js index 97e5c82d..d93953b1 100644 --- a/src/util.js +++ b/src/util.js @@ -2,10 +2,17 @@ var util = function() { var templates = { transformActions: '
  • Global transform...
  • ' , columnActions: ' \ -
  • Transform...
  • \ -
  • Delete this column
  • \ +
  • Transform...
  • \ +
  • Delete this column
  • \ +
  • Sort ascending
  • \ +
  • Sort descending
  • \ +
  • Hide this column
  • \ ' - , rowActions: '
  • Delete this row
  • ' + , rowActions: '
  • Delete this row
  • ' + , rootActions: ' \ + {{#columns}} \ +
  • Add column: {{.}}
  • \ + {{/columns}}' , cellEditor: ' \