diff --git a/demo/index.html b/demo/index.html index b385ba4b..48a0d5e0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,6 +28,10 @@ + + + + diff --git a/src/view-data-explorer.js b/src/view-data-explorer.js new file mode 100644 index 00000000..7e3c2258 --- /dev/null +++ b/src/view-data-explorer.js @@ -0,0 +1,171 @@ +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +// Views module following classic module pattern +(function($, my) { + +// The primary view for the entire application. +// +// It should be initialized with a recline.Model.Dataset object and an existing +// dom element to attach to (the existing DOM element is important for +// rendering of FlotGraph subview). +// +// To pass in configuration options use the config key in initialization hash +// e.g. +// +// var explorer = new DataExplorer({ +// config: {...} +// }) +// +// Config options: +// +// * displayCount: how many documents to display initially (default: 10) +// * readOnly: true/false (default: false) value indicating whether to +// operate in read-only mode (hiding all editing options). +// +// All other views as contained in this one. +my.DataExplorer = Backbone.View.extend({ + template: ' \ +
\ +
\ + \ +
\ + \ + \ +
\ +
\ + \ + \ +
\ + ', + + events: { + 'submit form.display-count': 'onDisplayCountUpdate' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + this.config = _.extend({ + displayCount: 50 + , readOnly: false + }, + options.config); + if (this.config.readOnly) { + this.setReadOnly(); + } + // Hash of 'page' views (i.e. those for whole page) keyed by page name + this.pageViews = { + grid: new my.DataTable({ + model: this.model, + config: this.config + }) + , graph: new my.FlotGraph({ + model: this.model + }) + }; + // this must be called after pageViews are created + this.render(); + + this.router = new Backbone.Router(); + this.setupRouting(); + + // retrieve basic data like headers etc + // note this.model and dataset returned are the same + this.model.fetch() + .done(function(dataset) { + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + self.query(); + }) + .fail(function(error) { + my.notify(error.message, {category: 'error', persist: true}); + }); + }, + + query: function() { + this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); + var queryObj = { + size: this.config.displayCount + }; + my.notify('Loading data', {loader: true}); + this.model.query(queryObj) + .done(function() { + my.clearNotifications(); + my.notify('Data loaded', {category: 'success'}); + }) + .fail(function(error) { + my.clearNotifications(); + my.notify(error.message, {category: 'error', persist: true}); + }); + }, + + onDisplayCountUpdate: function(e) { + e.preventDefault(); + this.query(); + }, + + setReadOnly: function() { + this.el.addClass('read-only'); + }, + + render: function() { + var tmplData = this.model.toTemplateJSON(); + tmplData.displayCount = this.config.displayCount; + var template = $.mustache(this.template, tmplData); + $(this.el).html(template); + var $dataViewContainer = this.el.find('.data-view-container'); + _.each(this.pageViews, function(view, pageName) { + $dataViewContainer.append(view.el) + }); + }, + + setupRouting: function() { + var self = this; + this.router.route('', 'grid', function() { + self.updateNav('grid'); + }); + this.router.route(/grid(\?.*)?/, 'view', function(queryString) { + self.updateNav('grid', queryString); + }); + this.router.route(/graph(\?.*)?/, 'graph', function(queryString) { + self.updateNav('graph', queryString); + // we have to call here due to fact plot may not have been able to draw + // if it was hidden until now - see comments in FlotGraph.redraw + qsParsed = parseQueryString(queryString); + if ('graph' in qsParsed) { + var chartConfig = JSON.parse(qsParsed['graph']); + _.extend(self.pageViews['graph'].chartConfig, chartConfig); + } + self.pageViews['graph'].redraw(); + }); + }, + + updateNav: function(pageName, queryString) { + this.el.find('.navigation li').removeClass('active'); + var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); + $el.parent().addClass('active'); + // show the specific page + _.each(this.pageViews, function(view, pageViewName) { + if (pageViewName === pageName) { + view.el.show(); + } else { + view.el.hide(); + } + }); + } +}); + +})(jQuery, recline.View); + + diff --git a/src/view-data-table.js b/src/view-data-table.js new file mode 100644 index 00000000..ff4a8c26 --- /dev/null +++ b/src/view-data-table.js @@ -0,0 +1,298 @@ +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +// Views module following classic module pattern +(function($, my) { + +// DataTable provides a tabular view on a Dataset. +// +// Initialize it with a recline.Dataset object. +my.DataTable = Backbone.View.extend({ + tagName: "div", + className: "data-table-container", + + initialize: function() { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); + this.model.currentDocuments.bind('remove', this.render); + this.state = {}; + this.hiddenHeaders = []; + }, + + events: { + 'click .column-header-menu': 'onColumnHeaderClick' + , 'click .row-header-menu': 'onRowHeaderClick' + , 'click .root-header-menu': 'onRootHeaderClick' + , 'click .data-table-menu li a': 'onMenuClick' + }, + + // TODO: delete or re-enable (currently this code is not used from anywhere except deprecated or disabled methods (see above)). + // showDialog: function(template, data) { + // if (!data) data = {}; + // util.show('dialog'); + // util.render(template, 'dialog-content', data); + // util.observeExit($('.dialog-content'), function() { + // util.hide('dialog'); + // }) + // $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + // }, + + + // ====================================================== + // Column and row menus + + onColumnHeaderClick: function(e) { + this.state.currentColumn = $(e.target).siblings().text(); + util.position('data-table-menu', e); + util.render('columnActions', 'data-table-menu'); + }, + + onRowHeaderClick: function(e) { + this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); + util.position('data-table-menu', e); + util.render('rowActions', 'data-table-menu'); + }, + + onRootHeaderClick: function(e) { + util.position('data-table-menu', e); + util.render('rootActions', 'data-table-menu', {'columns': this.hiddenHeaders}); + }, + + onMenuClick: function(e) { + var self = this; + e.preventDefault(); + var actions = { + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + transform: function() { self.showTransformDialog('transform') }, + sortAsc: function() { self.setColumnSort('asc') }, + sortDesc: function() { self.setColumnSort('desc') }, + hideColumn: function() { self.hideColumn() }, + showColumn: function() { self.showColumn(e) }, + // TODO: Delete or re-implement ... + csv: function() { window.location.href = app.csvUrl }, + json: function() { window.location.href = "_rewrite/api/json" }, + urlImport: function() { showDialog('urlImport') }, + pasteImport: function() { showDialog('pasteImport') }, + uploadImport: function() { showDialog('uploadImport') }, + // END TODO + deleteColumn: function() { + var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents."; + // TODO: + alert('This function needs to be re-implemented'); + return; + if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); + }, + deleteRow: function() { + var doc = _.find(self.model.currentDocuments.models, function(doc) { + // important this is == as the currentRow will be string (as comes + // from DOM) while id may be int + return doc.id == self.state.currentRow + }); + doc.destroy().then(function() { + self.model.currentDocuments.remove(doc); + my.notify("Row deleted successfully"); + }) + .fail(function(err) { + my.notify("Errorz! " + err) + }) + } + } + util.hide('data-table-menu'); + actions[$(e.target).attr('data-action')](); + }, + + showTransformColumnDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.ColumnTransform({ + model: this.model + }); + view.state = this.state; + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + showTransformDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new recline.View.DataTransform({ + }); + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + setColumnSort: function(order) { + var query = _.extend(this.model.queryState, {sort: [[this.state.currentColumn, order]]}); + this.model.query(query); + }, + + hideColumn: function() { + this.hiddenHeaders.push(this.state.currentColumn); + this.render(); + }, + + showColumn: function(e) { + this.hiddenHeaders = _.without(this.hiddenHeaders, $(e.target).data('column')); + this.render(); + }, + + // ====================================================== + // Core Templating + template: ' \ + \ + \ + \ + \ + \ + {{#notEmpty}} \ + \ + {{/notEmpty}} \ + {{#headers}} \ + \ + {{/headers}} \ + \ + \ + \ +
\ +
\ + \ + \ +
\ +
\ +
\ + \ + {{.}} \ +
\ + \ +
\ + ', + + toTemplateJSON: function() { + var modelData = this.model.toJSON() + modelData.notEmpty = ( this.headers.length > 0 ) + modelData.headers = this.headers; + return modelData; + }, + render: function() { + var self = this; + this.headers = _.filter(this.model.get('headers'), function(header) { + return _.indexOf(self.hiddenHeaders, header) == -1; + }); + var htmls = $.mustache(this.template, this.toTemplateJSON()); + this.el.html(htmls); + this.model.currentDocuments.forEach(function(doc) { + var tr = $(''); + self.el.find('tbody').append(tr); + var newView = new my.DataTableRow({ + model: doc, + el: tr, + headers: self.headers, + }); + newView.render(); + }); + this.el.toggleClass('no-hidden', (self.hiddenHeaders.length == 0)); + return this; + } +}); + +// DataTableRow View for rendering an individual document. +// +// Since we want this to update in place it is up to creator to provider the element to attach to. +// In addition you must pass in a headers in the constructor options. This should be list of headers for the DataTable. +my.DataTableRow = Backbone.View.extend({ + initialize: function(options) { + _.bindAll(this, 'render'); + this._headers = options.headers; + this.el = $(this.el); + this.model.bind('change', this.render); + }, + + template: ' \ + \ + {{#cells}} \ + \ +
\ +   \ +
{{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"); + } +}); + +})(jQuery, recline.View); + + diff --git a/src/view-flot-graph.js b/src/view-flot-graph.js new file mode 100644 index 00000000..4436a5a2 --- /dev/null +++ b/src/view-flot-graph.js @@ -0,0 +1,232 @@ +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +// Views module following classic module pattern +(function($, my) { + +// Graph view for a Dataset using Flot graphing library. +// +// 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'); + } +}); + +})(jQuery, recline.View); + diff --git a/src/view-transform-dialog.js b/src/view-transform-dialog.js new file mode 100644 index 00000000..7d80cd4f --- /dev/null +++ b/src/view-transform-dialog.js @@ -0,0 +1,206 @@ +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +// Views module following classic module pattern +(function($, my) { + +// View (Dialog) for doing data transformations on whole dataset. +my.DataTransform = Backbone.View.extend({ + className: 'transform-view', + template: ' \ +
\ + Recursive transform on all rows \ +
\ +
\ +
\ +

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

\ + \ + \ + \ + \ + \ + \ +
\ +
\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
\ + Expression \ +
\ +
\ + \ +
\ +
\ + No syntax error. \ +
\ +
\ + Preview \ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ + \ + ', + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + this.el.html(this.template); + } +}); + + +// View (Dialog) for doing data transformations (on columns of data). +my.ColumnTransform = Backbone.View.extend({ + className: 'transform-column-view', + 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); + } +}); + +})(jQuery, recline.View); diff --git a/src/view.js b/src/view.js index bb900938..5e79a62a 100644 --- a/src/view.js +++ b/src/view.js @@ -1,9 +1,8 @@ this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; // Views module following classic module pattern -recline.View = function($) { - -var my = {}; +(function($, my) { // Parse a URL query string (?xyz=abc...) into a dictionary. function parseQueryString(q) { @@ -64,882 +63,5 @@ my.clearNotifications = function() { $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, - config: this.config - }) - , graph: new my.FlotGraph({ - model: this.model - }) - }; - // this must be called after pageViews are created - this.render(); - - this.router = new Backbone.Router(); - this.setupRouting(); - - // retrieve basic data like headers etc - // note this.model and dataset returned are the same - this.model.fetch() - .done(function(dataset) { - self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - self.query(); - }) - .fail(function(error) { - my.notify(error.message, {category: 'error', persist: true}); - }); - }, - - query: function() { - this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); - var queryObj = { - size: this.config.displayCount - }; - my.notify('Loading data', {loader: true}); - this.model.query(queryObj) - .done(function() { - my.clearNotifications(); - my.notify('Data loaded', {category: 'success'}); - }) - .fail(function(error) { - my.clearNotifications(); - my.notify(error.message, {category: 'error', persist: true}); - }); - }, - - onDisplayCountUpdate: function(e) { - e.preventDefault(); - this.query(); - }, - - setReadOnly: function() { - this.el.addClass('read-only'); - }, - - render: function() { - var tmplData = this.model.toTemplateJSON(); - tmplData.displayCount = this.config.displayCount; - var template = $.mustache(this.template, tmplData); - $(this.el).html(template); - var $dataViewContainer = this.el.find('.data-view-container'); - _.each(this.pageViews, function(view, pageName) { - $dataViewContainer.append(view.el) - }); - }, - - setupRouting: function() { - var self = this; - this.router.route('', 'grid', function() { - self.updateNav('grid'); - }); - this.router.route(/grid(\?.*)?/, 'view', function(queryString) { - self.updateNav('grid', queryString); - }); - this.router.route(/graph(\?.*)?/, 'graph', function(queryString) { - self.updateNav('graph', queryString); - // we have to call here due to fact plot may not have been able to draw - // if it was hidden until now - see comments in FlotGraph.redraw - qsParsed = parseQueryString(queryString); - if ('graph' in qsParsed) { - var chartConfig = JSON.parse(qsParsed['graph']); - _.extend(self.pageViews['graph'].chartConfig, chartConfig); - } - self.pageViews['graph'].redraw(); - }); - }, - - updateNav: function(pageName, queryString) { - this.el.find('.navigation li').removeClass('active'); - var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); - $el.parent().addClass('active'); - // show the specific page - _.each(this.pageViews, function(view, pageViewName) { - if (pageViewName === pageName) { - view.el.show(); - } else { - view.el.hide(); - } - }); - } -}); - -// 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(); - }); - this.el.toggleClass('no-hidden', (self.hiddenHeaders.length == 0)); - return this; - } -}); - -// DataTableRow View for rendering an individual document. -// -// Since we want this to update in place it is up to creator to provider the element to attach to. -// In addition you must pass in a headers in the constructor options. This should be list of headers for the DataTable. -my.DataTableRow = Backbone.View.extend({ - initialize: function(options) { - _.bindAll(this, 'render'); - this._headers = options.headers; - this.el = $(this.el); - this.model.bind('change', this.render); - }, - - template: ' \ - \ - {{#cells}} \ - \ -
\ -   \ -
{{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); +})(jQuery, recline.View);