// 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 // // A model has the following (non-Backbone) attributes: // // @property {FieldList} fields: (aka columns) is a `FieldList` listing all the // fields on this Dataset (this can be set explicitly, or, will be set by // Dataset.fetch() or Dataset.query() // // @property {DocumentList} currentDocuments: a `DocumentList` containing the // Documents we have currently loaded for viewing (updated by calling query // method) // // @property {number} docCount: total number of documents in this dataset // // @property {Backend} backend: the Backend (instance) for this Dataset. // // @property {Query} queryState: `Query` object which stores current // queryState. queryState may be edited by other components (e.g. a query // editor view) changes will trigger a Dataset query. // // @property {FacetList} facets: FacetList object containing all current // Facets. my.Dataset = Backbone.Model.extend({ __type__: 'Dataset', // ### initialize // // Sets up instance properties (see above) // // @param {Object} model: standard set of model attributes passed to Backbone models // // @param {Object or String} backend: Backend instance (see // `recline.Backend.Base`) or a string specifying that instance. The // string specifying may be a full class path e.g. // 'recline.Backend.ElasticSearch' or a simple name e.g. // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in // recline.Backend module) initialize: function(model, backend) { _.bindAll(this, 'query'); this.backend = backend; if (typeof(backend) === 'string') { this.backend = this._backendFromString(backend); } this.fields = new my.FieldList(); this.currentDocuments = new my.DocumentList(); this.facets = new my.FacetList(); this.docCount = null; this.queryState = new my.Query(); this.queryState.bind('change', this.query); this.queryState.bind('facet:add', this.query); }, // ### query // // AJAX method with promise API to get documents from the backend. // // It will query based on current query state (given by this.queryState) // updated by queryObj (if provided). // // Resulting DocumentList are used to reset this.currentDocuments and are // also returned. query: function(queryObj) { var self = this; this.trigger('query:start'); var actualQuery = self._prepareQuery(queryObj); var dfd = $.Deferred(); this.backend.query(this, actualQuery).done(function(queryResult) { self.docCount = queryResult.total; var docs = _.map(queryResult.hits, function(hit) { var _doc = new my.Document(hit._source); _doc.backend = self.backend; _doc.dataset = self; return _doc; }); self.currentDocuments.reset(docs); if (queryResult.facets) { var facets = _.map(queryResult.facets, function(facetResult, facetId) { facetResult.id = facetId; return new my.Facet(facetResult); }); self.facets.reset(facets); } self.trigger('query:done'); dfd.resolve(self.currentDocuments); }) .fail(function(arguments) { self.trigger('query:fail', arguments); dfd.reject(arguments); }); return dfd.promise(); }, _prepareQuery: function(newQueryObj) { if (newQueryObj) { this.queryState.set(newQueryObj); } var out = this.queryState.toJSON(); return out; }, toTemplateJSON: function() { var data = this.toJSON(); data.docCount = this.docCount; data.fields = this.fields.toJSON(); return data; }, // ### _backendFromString(backendString) // // See backend argument to initialize for details _backendFromString: function(backendString) { var parts = backendString.split('.'); // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class var current = window; for(ii=0;ii // { // backend: {backend type - i.e. value of dataset.backend.__type__} // dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler } // // convenience - if url provided and dataste not this be used as dataset url // url: {dataset url} // ... // } my.Dataset.restore = function(state) { var dataset = null; // hack-y - restoring a memory dataset does not mean much ... if (state.backend === 'memory') { dataset = recline.Backend.Memory.createDataset( [{stub: 'this is a stub dataset because we do not restore memory datasets'}], [], state.dataset // metadata ); } else { var datasetInfo = { url: state.url }; dataset = new recline.Model.Dataset( datasetInfo, state.backend ); } return dataset; }; // ## A Document (aka Row) // // A single entry or row in the dataset my.Document = Backbone.Model.extend({ __type__: 'Document', initialize: function() { _.bindAll(this, 'getFieldValue'); }, // ### getFieldValue // // For the provided Field get the corresponding rendered computed data value // for this document. getFieldValue: function(field) { var val = this.get(field.id); if (field.deriver) { val = field.deriver(val, field, this); } if (field.renderer) { val = field.renderer(val, field, this); } return val; } }); // ## A Backbone collection of Documents my.DocumentList = Backbone.Collection.extend({ __type__: 'DocumentList', model: my.Document }); // ## A Field (aka Column) on a Dataset // // Following (Backbone) attributes as standard: // // * id: a unique identifer for this field- usually this should match the key in the documents hash // * label: (optional: defaults to id) the visible label used for this field // * type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on // * format: (optional) used to indicate how the data should be formatted. For example: // * type=date, format=yyyy-mm-dd // * type=float, format=percentage // * type=string, format=markdown (render as markdown if Showdown available) // * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below). // // Following additional instance properties: // // @property {Function} renderer: a function to render the data for this field. // Signature: function(value, field, doc) where value is the value of this // cell, field is corresponding field object and document is the document // object. Note that implementing functions can ignore arguments (e.g. // function(value) would be a valid formatter function). // // @property {Function} deriver: a function to derive/compute the value of data // in this field as a function of this field's value (if any) and the current // document, its signature and behaviour is the same as for renderer. Use of // this function allows you to define an entirely new value for data in this // field. This provides support for a) 'derived/computed' fields: i.e. fields // whose data are functions of the data in other fields b) transforming the // value of this field prior to rendering. // // #### Default renderers // // * string // * no format provided: pass through but convert http:// to hyperlinks // * format = plain: do no processing on the source text // * format = markdown: process as markdown (if Showdown library available) // * float // * format = percentage: format as a percentage my.Field = Backbone.Model.extend({ // ### defaults - define default values defaults: { label: null, type: 'string', format: null, is_derived: false }, // ### initialize // // @param {Object} data: standard Backbone model attributes // // @param {Object} options: renderer and/or deriver functions. initialize: function(data, options) { // if a hash not passed in the first argument throw error if ('0' in data) { throw new Error('Looks like you did not pass a proper hash with id to Field constructor'); } if (this.attributes.label === null) { this.set({label: this.id}); } if (options) { this.renderer = options.renderer; this.deriver = options.deriver; } if (!this.renderer) { this.renderer = this.defaultRenderers[this.get('type')]; } }, defaultRenderers: { object: function(val, field, doc) { return JSON.stringify(val); }, 'float': function(val, field, doc) { var format = field.get('format'); if (format === 'percentage') { return val + '%'; } return val; }, 'string': function(val, field, doc) { var format = field.get('format'); if (format === 'markdown') { if (typeof Showdown !== 'undefined') { var showdown = new Showdown.converter(); out = showdown.makeHtml(val); return out; } else { return val; } } else if (format == 'plain') { return val; } else { // as this is the default and default type is string may get things // here that are not actually strings if (val && typeof val === 'string') { val = val.replace(/(https?:\/\/[^ ]+)/g, '$1'); } return val } } } }); my.FieldList = Backbone.Collection.extend({ model: my.Field }); // ## Query // // Query instances encapsulate a query to the backend (see query method on backend). Useful both // for creating queries and for storing and manipulating query state - // e.g. from a query editor). // // **Query Structure and format** // // Query structure should follow that of [ElasticSearch query // language](http://www.elasticsearch.org/guide/reference/api/search/). // // **NB: It is up to specific backends how to implement and support this query // structure. Different backends might choose to implement things differently // or not support certain features. Please check your backend for details.** // // Query object has the following key attributes: // // * size (=limit): number of results to return // * from (=offset): offset into result set - http://www.elasticsearch.org/guide/reference/api/search/from-size.html // * sort: sort order - // * query: Query in ES Query DSL // * filter: See filters and Filtered Query // * fields: set of fields to return - http://www.elasticsearch.org/guide/reference/api/search/fields.html // * facets: specification of facets - see http://www.elasticsearch.org/guide/reference/api/search/facets/ // // Additions: // // * q: either straight text or a hash will map directly onto a [query_string // query](http://www.elasticsearch.org/guide/reference/query-dsl/query-string-query.html) // in backend // // * Of course this can be re-interpreted by different backends. E.g. some // may just pass this straight through e.g. for an SQL backend this could be // the full SQL query // // * filters: dict of ElasticSearch filters. These will be and-ed together for // execution. // // **Examples** // //
// {
//    q: 'quick brown fox',
//    filters: [
//      { term: { 'owner': 'jones' } }
//    ]
// }
// 
my.Query = Backbone.Model.extend({ defaults: function() { return { size: 100, from: 0, facets: {}, // // , filter: {} filters: [] }; }, // #### addTermFilter // // Set (update or add) a terms filter to filters // // See addTermFilter: function(fieldId, value) { var filters = this.get('filters'); var filter = { term: {} }; filter.term[fieldId] = value; filters.push(filter); this.set({filters: filters}); // change does not seem to be triggered automatically if (value) { this.trigger('change'); } else { // adding a new blank filter and do not want to trigger a new query this.trigger('change:filters:new-blank'); } }, // ### removeFilter // // Remove a filter from filters at index filterIndex removeFilter: function(filterIndex) { var filters = this.get('filters'); filters.splice(filterIndex, 1); this.set({filters: filters}); this.trigger('change'); }, // ### addFacet // // Add a Facet to this query // // See addFacet: function(fieldId) { var facets = this.get('facets'); // Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field) if (_.contains(_.keys(facets), fieldId)) { return; } facets[fieldId] = { terms: { field: fieldId } }; this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); }, addHistogramFacet: function(fieldId) { var facets = this.get('facets'); facets[fieldId] = { date_histogram: { field: fieldId, interval: 'day' } }; this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); } }); // ## A Facet (Result) // // Object to store Facet information, that is summary information (e.g. values // and counts) about a field obtained by some faceting method on the // backend. // // Structure of a facet follows that of Facet results in ElasticSearch, see: // // // Specifically the object structure of a facet looks like (there is one // addition compared to ElasticSearch: the "id" field which corresponds to the // key used to specify this facet in the facet query): // //
// {
//   "id": "id-of-facet",
//   // type of this facet (terms, range, histogram etc)
//   "_type" : "terms",
//   // total number of tokens in the facet
//   "total": 5,
//   // @property {number} number of documents which have no value for the field
//   "missing" : 0,
//   // number of facet values not included in the returned facets
//   "other": 0,
//   // term object ({term: , count: ...})
//   "terms" : [ {
//       "term" : "foo",
//       "count" : 2
//     }, {
//       "term" : "bar",
//       "count" : 2
//     }, {
//       "term" : "baz",
//       "count" : 1
//     }
//   ]
// }
// 
my.Facet = Backbone.Model.extend({ defaults: function() { return { _type: 'terms', total: 0, other: 0, missing: 0, terms: [] }; } }); // ## A Collection/List of Facets my.FacetList = Backbone.Collection.extend({ model: my.Facet }); // ## Object State // // Convenience Backbone model for storing (configuration) state of objects like Views. my.ObjectState = Backbone.Model.extend({ }); // ## Backbone.sync // // Override Backbone.sync to hand off to sync function in relevant backend Backbone.sync = function(method, model, options) { return model.backend.sync(method, model, options); }; }(jQuery, this.recline.Model)); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.Util = this.recline.Util || {}; (function(my) { // ## Miscellaneous Utilities var urlPathRegex = /^([^?]+)(\?.*)?/; // Parse the Hash section of a URL into path and query string my.parseHashUrl = function(hashUrl) { var parsed = urlPathRegex.exec(hashUrl); if (parsed === null) { return {}; } else { return { path: parsed[1], query: parsed[2] || '' }; } }; // Parse a URL query string (?xyz=abc...) into a dictionary. my.parseQueryString = function(q) { if (!q) { return {}; } 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; }; // Parse the query string out of the URL hash my.parseHashQueryString = function() { q = my.parseHashUrl(window.location.hash).query; return my.parseQueryString(q); }; // Compse a Query String my.composeQueryString = function(queryParams) { var queryString = '?'; var items = []; $.each(queryParams, function(key, value) { if (typeof(value) === 'object') { value = JSON.stringify(value); } items.push(key + '=' + encodeURIComponent(value)); }); queryString += items.join('&'); return queryString; }; my.getNewHashForQueryString = function(queryParams) { var queryPart = my.composeQueryString(queryParams); if (window.location.hash) { // slice(1) to remove # at start return window.location.hash.split('?')[0].slice(1) + queryPart; } else { return queryPart; } }; my.setHashQueryString = function(queryParams) { window.location.hash = my.getNewHashForQueryString(queryParams); }; })(this.recline.Util); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { // ## Graph view for a Dataset using Flot graphing library. // // Initialization arguments (in a hash in first parameter): // // * model: recline.Model.Dataset // * state: (optional) 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.Graph = Backbone.View.extend({ tagName: "div", className: "recline-graph", template: ' \
\
\
\ \
\ \
\ \
\ \
\
\
\
\
\ \
\ \
\
\
\
\

Hey there!

\

There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.

\

Please tell us by using the menu on the right and a graph will automatically appear.

\
\
\ \ ', templateSeriesEditor: ' \
\ \
\ \
\
\ ', events: { 'change form select': 'onEditorSubmit', 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries' }, initialize: function(options) { var self = this; this.el = $(this.el); _.bindAll(this, 'render', 'redraw'); this.needToRedraw = false; this.model.bind('change', this.render); this.model.fields.bind('reset', this.render); this.model.fields.bind('add', this.render); this.model.currentDocuments.bind('add', this.redraw); this.model.currentDocuments.bind('reset', this.redraw); // because we cannot redraw when hidden we may need when becoming visible this.bind('view:show', function() { if (this.needToRedraw) { self.redraw(); } }); var stateData = _.extend({ group: null, // so that at least one series chooser box shows up series: [], graphType: 'lines-and-points' }, options.state ); this.state = new recline.Model.ObjectState(stateData); this.render(); }, render: function() { var self = this; var tmplData = this.model.toTemplateJSON(); var htmls = Mustache.render(this.template, tmplData); $(this.el).html(htmls); this.$graph = this.el.find('.panel.graph'); // set up editor from state if (this.state.get('graphType')) { this._selectOption('.editor-type', this.state.get('graphType')); } if (this.state.get('group')) { this._selectOption('.editor-group', this.state.get('group')); } // ensure at least one series box shows up var tmpSeries = [""]; if (this.state.get('series').length > 0) { tmpSeries = this.state.get('series'); } _.each(tmpSeries, function(series, idx) { self.addSeries(idx); self._selectOption('.editor-series.js-series-' + idx, series); }); return this; }, // Private: Helper function to select an option from a select list // _selectOption: function(id,value){ var options = this.el.find(id + ' select > option'); if (options) { options.each(function(opt){ if (this.value == value) { $(this).attr('selected','selected'); return false; } }); } }, onEditorSubmit: function(e) { var select = this.el.find('.editor-group select'); var $editor = this; var $series = this.el.find('.editor-series select'); var series = $series.map(function () { return $(this).val(); }); var updatedState = { series: $.makeArray(series), group: this.el.find('.editor-group select').val(), graphType: this.el.find('.editor-type select').val() }; this.state.set(updatedState); 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 ((!areWeVisible || this.model.currentDocuments.length === 0)) { this.needToRedraw = true; return; } // check we have something to plot if (this.state.get('group') && this.state.get('series')) { var series = this.createSeries(); var options = this.getGraphOptions(this.state.attributes.graphType); this.plot = $.plot(this.$graph, series, options); this.setupTooltips(); } }, // ### getGraphOptions // // Get options for Flot Graph // // needs to be function as can depend on state // // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; // special tickformatter to show labels rather than numbers // TODO: we should really use tickFormatter and 1 interval ticks if (and // only if) x-axis values are non-numeric // However, that is non-trivial to work out from a dataset (datasets may // have no field type info). Thus at present we only do this for bars. var tickFormatter = function (val) { if (self.model.currentDocuments.models[val]) { var out = self.model.currentDocuments.models[val].get(self.state.attributes.group); // if the value was in fact a number we want that not the if (typeof(out) == 'number') { return val; } else { return out; } } return val; }; var xaxis = {}; // check for time series on x-axis if (this.model.fields.get(this.state.get('group')).get('type') === 'date') { xaxis.mode = 'time'; xaxis.timeformat = '%y-%b'; } var optionsPerGraphType = { lines: { series: { lines: { show: true } }, xaxis: xaxis }, points: { series: { points: { show: true } }, xaxis: xaxis, grid: { hoverable: true, clickable: true } }, 'lines-and-points': { series: { points: { show: true }, lines: { show: true } }, xaxis: xaxis, grid: { hoverable: true, clickable: true } }, bars: { series: { lines: {show: false}, bars: { show: true, barWidth: 1, align: "center", fill: true, horizontal: true } }, grid: { hoverable: true, clickable: true }, yaxis: { tickSize: 1, tickLength: 1, tickFormatter: tickFormatter, min: -0.5, max: self.model.currentDocuments.length - 0.5 } } }; return optionsPerGraphType[typeId]; }, setupTooltips: function() { var self = this; function showTooltip(x, y, contents) { $('
' + contents + '
').css( { position: 'absolute', display: 'none', top: y + 5, left: x + 5, border: '1px solid #fdd', padding: '2px', 'background-color': '#fee', opacity: 0.80 }).appendTo("body").fadeIn(200); } var previousPoint = null; this.$graph.bind("plothover", function (event, pos, item) { if (item) { if (previousPoint != item.datapoint) { previousPoint = item.datapoint; $("#flot-tooltip").remove(); var x = item.datapoint[0]; var y = item.datapoint[1]; // it's horizontal so we have to flip if (self.state.attributes.graphType === 'bars') { var _tmp = x; x = y; y = _tmp; } // convert back from 'index' value on x-axis (e.g. in cases where non-number values) if (self.model.currentDocuments.models[x]) { x = self.model.currentDocuments.models[x].get(self.state.attributes.group); } else { x = x.toFixed(2); } y = y.toFixed(2); // is it time series var xfield = self.model.fields.get(self.state.attributes.group); var isDateTime = xfield.get('type') === 'date'; if (isDateTime) { x = new Date(parseInt(x)).toLocaleDateString(); } var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { group: self.state.attributes.group, x: x, series: item.series.label, y: y }); showTooltip(item.pageX, item.pageY, content); } } else { $("#flot-tooltip").remove(); previousPoint = null; } }); }, createSeries: function () { var self = this; var series = []; _.each(this.state.attributes.series, function(field) { var points = []; _.each(self.model.currentDocuments.models, function(doc, index) { var xfield = self.model.fields.get(self.state.attributes.group); var x = doc.getFieldValue(xfield); // time series var isDateTime = xfield.get('type') === 'date'; if (isDateTime) { x = new Date(x); } var yfield = self.model.fields.get(field); var y = doc.getFieldValue(yfield); if (typeof x === 'string') { x = index; } // horizontal bar chart if (self.state.attributes.graphType == 'bars') { points.push([y, x]); } else { points.push([x, y]); } }); series.push({data: points, label: field}); }); return series; }, // Public: Adds a new empty series select box to the editor. // // @param [int] idx index of this series in the list of series // // Returns itself. addSeries: function (idx) { var data = _.extend({ seriesIndex: idx, seriesName: String.fromCharCode(idx + 64 + 1), }, this.model.toTemplateJSON()); var htmls = Mustache.render(this.templateSeriesEditor, data); this.el.find('.editor-series-group').append(htmls); return this; }, _onAddSeries: function(e) { e.preventDefault(); this.addSeries(this.state.get('series').length); }, // 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.onEditorSubmit(); } }); })(jQuery, recline.View); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { // ## (Data) Grid Dataset View // // Provides a tabular view on a Dataset. // // Initialize it with a `recline.Model.Dataset`. my.Grid = Backbone.View.extend({ tagName: "div", className: "recline-grid-container", initialize: function(modelEtc) { var self = this; this.el = $(this.el); _.bindAll(this, 'render', 'onHorizontalScroll'); this.model.currentDocuments.bind('add', this.render); this.model.currentDocuments.bind('reset', this.render); this.model.currentDocuments.bind('remove', this.render); this.tempState = {}; var state = _.extend({ hiddenFields: [] }, modelEtc.state ); this.state = new recline.Model.ObjectState(state); }, events: { 'click .column-header-menu .data-table-menu li a': 'onColumnHeaderClick', 'click .row-header-menu': 'onRowHeaderClick', 'click .root-header-menu': 'onRootHeaderClick', 'click .data-table-menu li a': 'onMenuClick', // does not work here so done at end of render function // 'scroll .recline-grid tbody': 'onHorizontalScroll' }, // ====================================================== // Column and row menus onColumnHeaderClick: function(e) { this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field'); }, onRowHeaderClick: function(e) { this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id'); }, onRootHeaderClick: function(e) { var tmpl = ' \ {{#columns}} \
  • Show column: {{.}}
  • \ {{/columns}}'; var tmp = Mustache.render(tmpl, {'columns': this.state.get('hiddenFields')}); this.el.find('.root-header-menu .dropdown-menu').html(tmp); }, onMenuClick: function(e) { var self = this; e.preventDefault(); var actions = { bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); }, facet: function() { self.model.queryState.addFacet(self.tempState.currentColumn); }, facet_histogram: function() { self.model.queryState.addHistogramFacet(self.tempState.currentColumn); }, filter: function() { self.model.queryState.addTermFilter(self.tempState.currentColumn, ''); }, sortAsc: function() { self.setColumnSort('asc'); }, sortDesc: function() { self.setColumnSort('desc'); }, hideColumn: function() { self.hideColumn(); }, showColumn: function() { self.showColumn(e); }, deleteRow: function() { var self = this; 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.tempState.currentRow; }); doc.destroy().then(function() { self.model.currentDocuments.remove(doc); self.trigger('recline:flash', {message: "Row deleted successfully"}); }).fail(function(err) { self.trigger('recline:flash', {message: "Errorz! " + err}); }); } }; actions[$(e.target).attr('data-action')](); }, showTransformColumnDialog: function() { var self = this; var view = new my.ColumnTransform({ model: this.model }); // pass the flash message up the chain view.bind('recline:flash', function(flash) { self.trigger('recline:flash', flash); }); view.state = this.tempState; view.render(); this.el.append(view.el); view.el.modal(); }, setColumnSort: function(order) { var sort = [{}]; sort[0][this.tempState.currentColumn] = {order: order}; this.model.query({sort: sort}); }, hideColumn: function() { var hiddenFields = this.state.get('hiddenFields'); hiddenFields.push(this.tempState.currentColumn); this.state.set({hiddenFields: hiddenFields}); // change event not being triggered (because it is an array?) so trigger manually this.state.trigger('change'); this.render(); }, showColumn: function(e) { var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column')); this.state.set({hiddenFields: hiddenFields}); this.render(); }, onHorizontalScroll: function(e) { var currentScroll = $(e.target).scrollLeft(); this.el.find('.recline-grid thead tr').scrollLeft(currentScroll); }, // ====================================================== // #### Templating template: ' \
    \ \ \ \ {{#notEmpty}} \ \ {{/notEmpty}} \ {{#fields}} \ \ {{/fields}} \ \ \ \ \
    \
    \ \ \
    \ \
    \ \ {{label}} \
    \
    \ ', toTemplateJSON: function() { var self = this; var modelData = this.model.toJSON(); modelData.notEmpty = ( this.fields.length > 0 ); // TODO: move this sort of thing into a toTemplateJSON method on Dataset? modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); }); // last header width = scroll bar - border (2px) */ modelData.lastHeaderWidth = this.scrollbarDimensions.width - 2; return modelData; }, render: function() { var self = this; this.fields = this.model.fields.filter(function(field) { return _.indexOf(self.state.get('hiddenFields'), field.id) == -1; }); this.scrollbarDimensions = this.scrollbarDimensions || this._scrollbarSize(); // skip measurement if already have dimensions var numFields = this.fields.length; // compute field widths (-20 for first menu col + 10px for padding on each col and finally 16px for the scrollbar) var fullWidth = self.el.width() - 20 - 10 * numFields - this.scrollbarDimensions.width; var width = parseInt(Math.max(50, fullWidth / numFields)); // if columns extend outside viewport then remainder is 0 var remainder = Math.max(fullWidth - numFields * width,0); _.each(this.fields, function(field, idx) { // add the remainder to the first field width so we make up full col if (idx == 0) { field.set({width: width+remainder}); } else { field.set({width: width}); } }); var htmls = Mustache.render(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.GridRow({ model: doc, el: tr, fields: self.fields }); newView.render(); }); // hide extra header col if no scrollbar to avoid unsightly overhang var $tbody = this.el.find('tbody')[0]; if ($tbody.scrollHeight <= $tbody.offsetHeight) { this.el.find('th.last-header').hide(); } this.el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0)); this.el.find('.recline-grid tbody').scroll(this.onHorizontalScroll); return this; }, // ### _scrollbarSize // // Measure width of a vertical scrollbar and height of a horizontal scrollbar. // // @return: { width: pixelWidth, height: pixelHeight } _scrollbarSize: function() { var $c = $("
    ").appendTo("body"); var dim = { width: $c.width() - $c[0].clientWidth + 1, height: $c.height() - $c[0].clientHeight }; $c.remove(); return dim; } }); // ## GridRow 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 FieldList in the constructor options. This should be list of fields for the Grid. // // Example: // //
    // var row = new GridRow({
    //   model: dataset-document,
    //     el: dom-element,
    //     fields: mydatasets.fields // a FieldList object
    //   });
    // 
    my.GridRow = Backbone.View.extend({ initialize: function(initData) { _.bindAll(this, 'render'); this._fields = initData.fields; this.el = $(this.el); this.model.bind('change', this.render); }, template: ' \ \
    \ \ \
    \ \ {{#cells}} \ \
    \   \
    {{{value}}}
    \
    \ \ {{/cells}} \ ', events: { 'click .data-table-cell-edit': 'onEditClick', 'click .data-table-cell-editor .okButton': 'onEditorOK', 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' }, toTemplateJSON: function() { var self = this; var doc = this.model; var cellData = this._fields.map(function(field) { return { field: field.id, width: field.get('width'), value: doc.getFieldValue(field) }; }); return { id: this.id, cells: cellData }; }, render: function() { this.el.attr('data-id', this.model.id); var html = Mustache.render(this.template, this.toTemplateJSON()); $(this.el).html(html); return this; }, // =================== // Cell Editor methods cellEditorTemplate: ' \ \ ', 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()); var templated = Mustache.render(this.cellEditorTemplate, {value: cell.text()}); cell.html(templated); }, onEditorOK: function(e) { var self = this; var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); var field = cell.parents('td').attr('data-field'); var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); var newData = {}; newData[field] = newValue; this.model.set(newData); this.trigger('recline:flash', {message: "Updating row...", loader: true}); this.model.save().then(function(response) { this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'}); }) .fail(function() { this.trigger('recline:flash', { message: '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); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { // ## Map view for a Dataset using Leaflet mapping library. // // This view allows to plot gereferenced documents on a map. The location // information can be provided either via a field with // [GeoJSON](http://geojson.org) objects or two fields with latitude and // longitude coordinates. // // Initialization arguments are as standard for Dataset Views. State object may // have the following (optional) configuration options: // //
    //   {
    //     // geomField if specified will be used in preference to lat/lon
    //     geomField: {id of field containing geometry in the dataset}
    //     lonField: {id of field containing longitude in the dataset}
    //     latField: {id of field containing latitude in the dataset}
    //   }
    // 
    my.Map = Backbone.View.extend({ tagName: 'div', className: 'recline-map', template: ' \
    \
    \
    \
    \ \ \
    \
    \ \
    \ \
    \ \
    \ \
    \
    \ \
    \
    \ \
    \
    \ \
    \ \
    \ \ \
    \
    \ ', // These are the default (case-insensitive) names of field that are used if found. // If not found, the user will need to define the fields via the editor. latitudeFieldNames: ['lat','latitude'], longitudeFieldNames: ['lon','longitude'], geometryFieldNames: ['geom','the_geom','geometry','spatial','location'], // Define here events for UI elements events: { 'click .editor-update-map': 'onEditorSubmit', 'change .editor-field-type': 'onFieldTypeChange', 'change #editor-auto-zoom': 'onAutoZoomChange' }, initialize: function(options) { var self = this; this.el = $(this.el); // Listen to changes in the fields this.model.fields.bind('change', function() { self._setupGeometryField(); }); this.model.fields.bind('add', this.render); this.model.fields.bind('reset', function(){ self._setupGeometryField() self.render() }); // Listen to changes in the documents this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)}); this.model.currentDocuments.bind('change', function(doc){ self.redraw('remove',doc); self.redraw('add',doc); }); this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)}); this.model.currentDocuments.bind('reset', function(){self.redraw('reset')}); this.bind('view:show',function(){ // If the div was hidden, Leaflet needs to recalculate some sizes // to display properly if (self.map){ self.map.invalidateSize(); if (self._zoomPending && self.autoZoom) { self._zoomToFeatures(); self._zoomPending = false; } } self.visible = true; }); this.bind('view:hide',function(){ self.visible = false; }); var stateData = _.extend({ geomField: null, lonField: null, latField: null }, options.state ); this.state = new recline.Model.ObjectState(stateData); this.autoZoom = true; this.mapReady = false; this.render(); }, // ### Public: Adds the necessary elements to the page. // // Also sets up the editor fields and the map if necessary. render: function() { var self = this; htmls = Mustache.render(this.template, this.model.toTemplateJSON()); $(this.el).html(htmls); this.$map = this.el.find('.panel.map'); if (this.geomReady && this.model.fields.length){ if (this.state.get('geomField')){ this._selectOption('editor-geom-field',this.state.get('geomField')); $('#editor-field-type-geom').attr('checked','checked').change(); } else{ this._selectOption('editor-lon-field',this.state.get('lonField')); this._selectOption('editor-lat-field',this.state.get('latField')); $('#editor-field-type-latlon').attr('checked','checked').change(); } } return this; }, // ### Public: Redraws the features on the map according to the action provided // // Actions can be: // // * reset: Clear all features // * add: Add one or n features (documents) // * remove: Remove one or n features (documents) // * refresh: Clear existing features and add all current documents redraw: function(action, doc){ var self = this; action = action || 'refresh'; // try to set things up if not already if (!self.geomReady){ self._setupGeometryField(); } if (!self.mapReady){ self._setupMap(); } if (this.geomReady && this.mapReady){ if (action == 'reset' || action == 'refresh'){ this.features.clearLayers(); this._add(this.model.currentDocuments.models); } else if (action == 'add' && doc){ this._add(doc); } else if (action == 'remove' && doc){ this._remove(doc); } if (this.autoZoom){ if (this.visible){ this._zoomToFeatures(); } else { this._zoomPending = true; } } } }, // // UI Event handlers // // Public: Update map with user options // // Right now the only configurable option is what field(s) contains the // location information. // onEditorSubmit: function(e){ e.preventDefault(); if ($('#editor-field-type-geom').attr('checked')){ this.state.set({ geomField: $('.editor-geom-field > select > option:selected').val(), lonField: null, latField: null }); } else { this.state.set({ geomField: null, lonField: $('.editor-lon-field > select > option:selected').val(), latField: $('.editor-lat-field > select > option:selected').val() }); } this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); this.redraw(); return false; }, // Public: Shows the relevant select lists depending on the location field // type selected. // onFieldTypeChange: function(e){ if (e.target.value == 'geom'){ $('.editor-field-type-geom').show(); $('.editor-field-type-latlon').hide(); } else { $('.editor-field-type-geom').hide(); $('.editor-field-type-latlon').show(); } }, onAutoZoomChange: function(e){ this.autoZoom = !this.autoZoom; }, // Private: Add one or n features to the map // // For each document passed, a GeoJSON geometry will be extracted and added // to the features layer. If an exception is thrown, the process will be // stopped and an error notification shown. // // Each feature will have a popup associated with all the document fields. // _add: function(docs){ var self = this; if (!(docs instanceof Array)) docs = [docs]; var count = 0; var wrongSoFar = 0; _.every(docs,function(doc){ count += 1; var feature = self._getGeometryFromDocument(doc); if (typeof feature === 'undefined' || feature === null){ // Empty field return true; } else if (feature instanceof Object){ // Build popup contents // TODO: mustache? html = '' for (key in doc.attributes){ if (!(self.state.get('geomField') && key == self.state.get('geomField'))){ html += '
    ' + key + ': '+ doc.attributes[key] + '
    '; } } feature.properties = {popupContent: html}; // Add a reference to the model id, which will allow us to // link this Leaflet layer to a Recline doc feature.properties.cid = doc.cid; try { self.features.addGeoJSON(feature); } catch (except) { wrongSoFar += 1; var msg = 'Wrong geometry value'; if (except.message) msg += ' (' + except.message + ')'; if (wrongSoFar <= 10) { self.trigger('recline:flash', {message: msg, category:'error'}); } } } else { wrongSoFar += 1 if (wrongSoFar <= 10) { self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'}); } } return true; }); }, // Private: Remove one or n features to the map // _remove: function(docs){ var self = this; if (!(docs instanceof Array)) docs = [docs]; _.each(docs,function(doc){ for (key in self.features._layers){ if (self.features._layers[key].cid == doc.cid){ self.features.removeLayer(self.features._layers[key]); } } }); }, // Private: Return a GeoJSON geomtry extracted from the document fields // _getGeometryFromDocument: function(doc){ if (this.geomReady){ if (this.state.get('geomField')){ var value = doc.get(this.state.get('geomField')); if (typeof(value) === 'string'){ // We *may* have a GeoJSON string representation try { return $.parseJSON(value); } catch(e) { } } else { // We assume that the contents of the field are a valid GeoJSON object return value; } } else if (this.state.get('lonField') && this.state.get('latField')){ // We'll create a GeoJSON like point object from the two lat/lon fields var lon = doc.get(this.state.get('lonField')); var lat = doc.get(this.state.get('latField')); if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) { return { type: 'Point', coordinates: [lon,lat] }; } } return null; } }, // Private: Check if there is a field with GeoJSON geometries or alternatively, // two fields with lat/lon values. // // If not found, the user can define them via the UI form. _setupGeometryField: function(){ var geomField, latField, lonField; this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); // should not overwrite if we have already set this (e.g. explicitly via state) if (!this.geomReady) { this.state.set({ geomField: this._checkField(this.geometryFieldNames), latField: this._checkField(this.latitudeFieldNames), lonField: this._checkField(this.longitudeFieldNames) }); this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); } }, // Private: Check if a field in the current model exists in the provided // list of names. // // _checkField: function(fieldNames){ var field; var modelFieldNames = this.model.fields.pluck('id'); for (var i = 0; i < fieldNames.length; i++){ for (var j = 0; j < modelFieldNames.length; j++){ if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase()) return modelFieldNames[j]; } } return null; }, // Private: Zoom to map to current features extent if any, or to the full // extent if none. // _zoomToFeatures: function(){ var bounds = this.features.getBounds(); if (bounds){ this.map.fitBounds(bounds); } else { this.map.setView(new L.LatLng(0, 0), 2); } }, // Private: Sets up the Leaflet map control and the features layer. // // The map uses a base layer from [MapQuest](http://www.mapquest.com) based // on [OpenStreetMap](http://openstreetmap.org). // _setupMap: function(){ this.map = new L.Map(this.$map.get(0)); var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png"; var osmAttribution = 'Map data © 2011 OpenStreetMap contributors, Tiles Courtesy of MapQuest '; var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'}); this.map.addLayer(bg); this.features = new L.GeoJSON(); this.features.on('featureparse', function (e) { if (e.properties && e.properties.popupContent){ e.layer.bindPopup(e.properties.popupContent); } if (e.properties && e.properties.cid){ e.layer.cid = e.properties.cid; } }); // This will be available in the next Leaflet stable release. // In the meantime we add it manually to our layer. this.features.getBounds = function(){ var bounds = new L.LatLngBounds(); this._iterateLayers(function (layer) { if (layer instanceof L.Marker){ bounds.extend(layer.getLatLng()); } else { if (layer.getBounds){ bounds.extend(layer.getBounds().getNorthEast()); bounds.extend(layer.getBounds().getSouthWest()); } } }, this); return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null; } this.map.addLayer(this.features); this.map.setView(new L.LatLng(0, 0), 2); this.mapReady = true; }, // Private: Helper function to select an option from a select list // _selectOption: function(id,value){ var options = $('.' + id + ' > select > option'); if (options){ options.each(function(opt){ if (this.value == value) { $(this).attr('selected','selected'); return false; } }); } } }); })(jQuery, recline.View); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { // ## SlickGrid Dataset View // // Provides a tabular view on a Dataset, based on SlickGrid. // // https://github.com/mleibman/SlickGrid // // Initialize it with a `recline.Model.Dataset`. my.SlickGrid = Backbone.View.extend({ tagName: "div", className: "recline-slickgrid", initialize: function(modelEtc) { 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); var state = _.extend({ hiddenColumns: [], columnsOrder: [], columnsSort: {}, columnsWidth: [], fitColumns: false }, modelEtc.state ); this.state = new recline.Model.ObjectState(state); this.bind('view:show',function(){ // If the div is hidden, SlickGrid will calculate wrongly some // sizes so we must render it explicitly when the view is visible if (!self.rendered){ if (!self.grid){ self.render(); } self.grid.init(); self.rendered = true; } self.visible = true; }); this.bind('view:hide',function(){ self.visible = false; }); }, events: { }, render: function() { var self = this; this.el = $(this.el); var options = { enableCellNavigation: true, enableColumnReorder: true, explicitInitialization: true, syncColumnCellResize: true, forceFitColumns: this.state.get('fitColumns') }; // We need all columns, even the hidden ones, to show on the column picker var columns = []; _.each(this.model.fields.toJSON(),function(field){ var column = {id:field['id'], name:field['label'], field:field['id'], sortable: true, minWidth: 80}; var widthInfo = _.find(self.state.get('columnsWidth'),function(c){return c.column == field.id}); if (widthInfo){ column['width'] = widthInfo.width; } columns.push(column); }); // Restrict the visible columns var visibleColumns = columns.filter(function(column) { return _.indexOf(self.state.get('hiddenColumns'), column.id) == -1; }); // Order them if there is ordering info on the state if (this.state.get('columnsOrder')){ visibleColumns = visibleColumns.sort(function(a,b){ return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id); }); columns = columns.sort(function(a,b){ return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id); }); } // Move hidden columns to the end, so they appear at the bottom of the // column picker var tempHiddenColumns = []; for (var i = columns.length -1; i >= 0; i--){ if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) == -1){ tempHiddenColumns.push(columns.splice(i,1)[0]); } } columns = columns.concat(tempHiddenColumns); var data = this.model.currentDocuments.toJSON(); this.grid = new Slick.Grid(this.el, data, visibleColumns, options); // Column sorting var gridSorter = function(field, ascending, grid, data){ data.sort(function(a, b){ var result = a[field] > b[field] ? 1 : a[field] < b[field] ? -1 : 0 ; return ascending ? result : -result; }); grid.setData(data); grid.updateRowCount(); grid.render(); } var sortInfo = this.state.get('columnsSort'); if (sortInfo){ var sortAsc = !(sortInfo['direction'] == 'desc'); gridSorter(sortInfo.column, sortAsc, self.grid, data); this.grid.setSortColumn(sortInfo.column, sortAsc); } this.grid.onSort.subscribe(function(e, args){ gridSorter(args.sortCol.field,args.sortAsc,self.grid,data); self.state.set({columnsSort:{ column:args.sortCol, direction: (args.sortAsc) ? 'asc':'desc' }}); }); this.grid.onColumnsReordered.subscribe(function(e, args){ self.state.set({columnsOrder: _.pluck(self.grid.getColumns(),'id')}); }); this.grid.onColumnsResized.subscribe(function(e, args){ var columns = args.grid.getColumns(); var defaultColumnWidth = args.grid.getOptions().defaultColumnWidth; var columnsWidth = []; _.each(columns,function(column){ if (column.width != defaultColumnWidth){ columnsWidth.push({column:column.id,width:column.width}); } }); self.state.set({columnsWidth:columnsWidth}); }); var columnpicker = new Slick.Controls.ColumnPicker(columns, this.grid, _.extend(options,{state:this.state})); if (self.visible){ self.grid.init(); self.rendered = true; } else { // Defer rendering until the view is visible self.rendered = false; } return this; } }); })(jQuery, recline.View); /* * Context menu for the column picker, adapted from * http://mleibman.github.com/SlickGrid/examples/example-grouping * */ (function ($) { function SlickColumnPicker(columns, grid, options) { var $menu; var columnCheckboxes; var defaults = { fadeSpeed:250 }; function init() { grid.onHeaderContextMenu.subscribe(handleHeaderContextMenu); options = $.extend({}, defaults, options); $menu = $('