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().then(function(dataset) { self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); self.query(); }); }, 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'}); }); }, 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);