this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; (function(my) { "use strict"; my.__type__ = 'dataproxy'; // URL for the dataproxy my.dataproxy_url = '//jsonpdataproxy.appspot.com'; // Timeout for dataproxy (after this time if no response we error) // Needed because use JSONP so do not receive e.g. 500 errors my.timeout = 5000; // use either jQuery or Underscore Deferred depending on what is available var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred; // ## load // // Load data from a URL via the [DataProxy](http://github.com/okfn/dataproxy). // // Returns array of field names and array of arrays for records my.fetch = function(dataset) { var data = { url: dataset.url, 'max-results': dataset.size || dataset.rows || 1000, type: dataset.format || '' }; var jqxhr = jQuery.ajax({ url: my.dataproxy_url, data: data, dataType: 'jsonp' }); var dfd = new Deferred(); _wrapInTimeout(jqxhr).done(function(results) { if (results.error) { dfd.reject(results.error); } dfd.resolve({ records: results.data, fields: results.fields, useMemoryStore: true }); }) .fail(function(args) { dfd.reject(args); }); return dfd.promise(); }; // ## _wrapInTimeout // // Convenience method providing a crude way to catch backend errors on JSONP calls. // Many of backends use JSONP and so will not get error messages and this is // a crude way to catch those errors. var _wrapInTimeout = function(ourFunction) { var dfd = new Deferred(); var timer = setTimeout(function() { dfd.reject({ message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds' }); }, my.timeout); ourFunction.done(function(args) { clearTimeout(timer); dfd.resolve(args); }) .fail(function(args) { clearTimeout(timer); dfd.reject(args); }) ; return dfd.promise(); }; }(this.recline.Backend.DataProxy)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Memory = this.recline.Backend.Memory || {}; (function(my) { "use strict"; my.__type__ = 'memory'; // private data - use either jQuery or Underscore Deferred depending on what is available var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred; // ## Data Wrapper // // Turn a simple array of JS objects into a mini data-store with // functionality like querying, faceting, updating (by ID) and deleting (by // ID). // // @param records list of hashes for each record/row in the data ({key: // value, key: value}) // @param fields (optional) list of field hashes (each hash defining a field // as per recline.Model.Field). If fields not specified they will be taken // from the data. my.Store = function(records, fields) { var self = this; this.records = records; // backwards compatability (in v0.5 records was named data) this.data = this.records; if (fields) { this.fields = fields; } else { if (records) { this.fields = _.map(records[0], function(value, key) { return {id: key, type: 'string'}; }); } } this.update = function(doc) { _.each(self.records, function(internalDoc, idx) { if(doc.id === internalDoc.id) { self.records[idx] = doc; } }); }; this.remove = function(doc) { var newdocs = _.reject(self.records, function(internalDoc) { return (doc.id === internalDoc.id); }); this.records = newdocs; }; this.save = function(changes, dataset) { var self = this; var dfd = new Deferred(); // TODO _.each(changes.creates) { ... } _.each(changes.updates, function(record) { self.update(record); }); _.each(changes.deletes, function(record) { self.remove(record); }); dfd.resolve(); return dfd.promise(); }, this.query = function(queryObj) { var dfd = new Deferred(); var numRows = queryObj.size || this.records.length; var start = queryObj.from || 0; var results = this.records; results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj); // TODO: this is not complete sorting! // What's wrong is we sort on the *last* entry in the sort list if there are multiple sort criteria _.each(queryObj.sort, function(sortObj) { var fieldName = sortObj.field; results = _.sortBy(results, function(doc) { var _out = doc[fieldName]; return _out; }); if (sortObj.order == 'desc') { results.reverse(); } }); var facets = this.computeFacets(results, queryObj); var out = { total: results.length, hits: results.slice(start, start+numRows), facets: facets }; dfd.resolve(out); return dfd.promise(); }; // in place filtering this._applyFilters = function(results, queryObj) { var filters = queryObj.filters; // register filters var filterFunctions = { term : term, terms : terms, range : range, geo_distance : geo_distance }; var dataParsers = { integer: function (e) { return parseFloat(e, 10); }, 'float': function (e) { return parseFloat(e, 10); }, number: function (e) { return parseFloat(e, 10); }, string : function (e) { return e.toString(); }, date : function (e) { return moment(e).valueOf(); }, datetime : function (e) { return new Date(e).valueOf(); } }; var keyedFields = {}; _.each(self.fields, function(field) { keyedFields[field.id] = field; }); function getDataParser(filter) { var fieldType = keyedFields[filter.field].type || 'string'; return dataParsers[fieldType]; } // filter records return _.filter(results, function (record) { var passes = _.map(filters, function (filter) { return filterFunctions[filter.type](record, filter); }); // return only these records that pass all filters return _.all(passes, _.identity); }); // filters definitions function term(record, filter) { var parse = getDataParser(filter); var value = parse(record[filter.field]); var term = parse(filter.term); return (value === term); } function terms(record, filter) { var parse = getDataParser(filter); var value = parse(record[filter.field]); var terms = parse(filter.terms).split(","); return (_.indexOf(terms, value) >= 0); } function range(record, filter) { var fromnull = (_.isUndefined(filter.from) || filter.from === null || filter.from === ''); var tonull = (_.isUndefined(filter.to) || filter.to === null || filter.to === ''); var parse = getDataParser(filter); var value = parse(record[filter.field]); var from = parse(fromnull ? '' : filter.from); var to = parse(tonull ? '' : filter.to); // if at least one end of range is set do not allow '' to get through // note that for strings '' <= {any-character} e.g. '' <= 'a' if ((!fromnull || !tonull) && value === '') { return false; } return ((fromnull || value >= from) && (tonull || value <= to)); } function geo_distance() { // TODO code here } }; // we OR across fields but AND across terms in query string this._applyFreeTextQuery = function(results, queryObj) { if (queryObj.q) { var terms = queryObj.q.split(' '); var patterns=_.map(terms, function(term) { return new RegExp(term.toLowerCase()); }); results = _.filter(results, function(rawdoc) { var matches = true; _.each(patterns, function(pattern) { var foundmatch = false; _.each(self.fields, function(field) { var value = rawdoc[field.id]; if ((value !== null) && (value !== undefined)) { value = value.toString(); } else { // value can be null (apparently in some cases) value = ''; } // TODO regexes? foundmatch = foundmatch || (pattern.test(value.toLowerCase())); // TODO: early out (once we are true should break to spare unnecessary testing) // if (foundmatch) return true; }); matches = matches && foundmatch; // TODO: early out (once false should break to spare unnecessary testing) // if (!matches) return false; }); return matches; }); } return results; }; this.computeFacets = function(records, queryObj) { var facetResults = {}; if (!queryObj.facets) { return facetResults; } _.each(queryObj.facets, function(query, facetId) { // TODO: remove dependency on recline.Model facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); facetResults[facetId].termsall = {}; }); // faceting _.each(records, function(doc) { _.each(queryObj.facets, function(query, facetId) { var fieldId = query.terms.field; var val = doc[fieldId]; var tmp = facetResults[facetId]; if (val) { tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1; } else { tmp.missing = tmp.missing + 1; } }); }); _.each(queryObj.facets, function(query, facetId) { var tmp = facetResults[facetId]; var terms = _.map(tmp.termsall, function(count, term) { return { term: term, count: count }; }); tmp.terms = _.sortBy(terms, function(item) { // want descending order return -item.count; }); tmp.terms = tmp.terms.slice(0, 10); }); return facetResults; }; }; }(this.recline.Backend.Memory)); // This file adds in full array method support in browsers that don't support it // see: http://stackoverflow.com/questions/2790001/fixing-javascript-array-functions-in-internet-explorer-indexof-foreach-etc // Add ECMA262-5 Array methods if not supported natively if (!('indexOf' in Array.prototype)) { Array.prototype.indexOf= function(find, i /*opt*/) { if (i===undefined) i= 0; if (i<0) i+= this.length; if (i<0) i= 0; for (var n= this.length; ithis.length-1) i= this.length-1; for (i++; i-->0;) /* i++ because from-argument is sadly inclusive */ if (i in this && this[i]===find) return i; return -1; }; } if (!('forEach' in Array.prototype)) { Array.prototype.forEach= function(action, that /*opt*/) { for (var i= 0, n= this.length; iDataset my.Dataset = Backbone.Model.extend({ constructor: function Dataset() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### initialize initialize: function() { var self = this; _.bindAll(this, 'query'); this.backend = null; if (this.get('backend')) { this.backend = this._backendFromString(this.get('backend')); } else { // try to guess backend ... if (this.get('records')) { this.backend = recline.Backend.Memory; } } this.fields = new my.FieldList(); this.records = new my.RecordList(); this._changes = { deletes: [], updates: [], creates: [] }; this.facets = new my.FacetList(); this.recordCount = null; this.queryState = new my.Query(); this.queryState.bind('change facet:add', function () { self.query(); // We want to call query() without any arguments. }); // store is what we query and save against // store will either be the backend or be a memory store if Backend fetch // tells us to use memory store this._store = this.backend; // if backend has a handleQueryResultFunction, use that this._handleResult = (this.backend != null && _.has(this.backend, 'handleQueryResult')) ? this.backend.handleQueryResult : this._handleQueryResult; if (this.backend == recline.Backend.Memory) { this.fetch(); } }, sync: function(method, model, options) { return this.backend.sync(method, model, options); }, // ### fetch // // Retrieve dataset and (some) records from the backend. fetch: function() { var self = this; var dfd = new Deferred(); if (this.backend !== recline.Backend.Memory) { this.backend.fetch(this.toJSON()) .done(handleResults) .fail(function(args) { dfd.reject(args); }); } else { // special case where we have been given data directly handleResults({ records: this.get('records'), fields: this.get('fields'), useMemoryStore: true }); } function handleResults(results) { // if explicitly given the fields // (e.g. var dataset = new Dataset({fields: fields, ...}) // use that field info over anything we get back by parsing the data // (results.fields) var fields = self.get('fields') || results.fields; var out = self._normalizeRecordsAndFields(results.records, fields); if (results.useMemoryStore) { self._store = new recline.Backend.Memory.Store(out.records, out.fields); } self.set(results.metadata); self.fields.reset(out.fields); self.query() .done(function() { dfd.resolve(self); }) .fail(function(args) { dfd.reject(args); }); } return dfd.promise(); }, // ### _normalizeRecordsAndFields // // Get a proper set of fields and records from incoming set of fields and records either of which may be null or arrays or objects // // e.g. fields = ['a', 'b', 'c'] and records = [ [1,2,3] ] => // fields = [ {id: a}, {id: b}, {id: c}], records = [ {a: 1}, {b: 2}, {c: 3}] _normalizeRecordsAndFields: function(records, fields) { // if no fields get them from records if (!fields && records && records.length > 0) { // records is array then fields is first row of records ... if (records[0] instanceof Array) { fields = records[0]; records = records.slice(1); } else { fields = _.map(_.keys(records[0]), function(key) { return {id: key}; }); } } // fields is an array of strings (i.e. list of field headings/ids) if (fields && fields.length > 0 && (fields[0] === null || typeof(fields[0]) != 'object')) { // Rename duplicate fieldIds as each field name needs to be // unique. var seen = {}; fields = _.map(fields, function(field, index) { if (field === null) { field = ''; } else { field = field.toString(); } // cannot use trim as not supported by IE7 var fieldId = field.replace(/^\s+|\s+$/g, ''); if (fieldId === '') { fieldId = '_noname_'; field = fieldId; } while (fieldId in seen) { seen[field] += 1; fieldId = field + seen[field]; } if (!(field in seen)) { seen[field] = 0; } // TODO: decide whether to keep original name as label ... // return { id: fieldId, label: field || fieldId } return { id: fieldId }; }); } // records is provided as arrays so need to zip together with fields // NB: this requires you to have fields to match arrays if (records && records.length > 0 && records[0] instanceof Array) { records = _.map(records, function(doc) { var tmp = {}; _.each(fields, function(field, idx) { tmp[field.id] = doc[idx]; }); return tmp; }); } return { fields: fields, records: records }; }, save: function() { var self = this; // TODO: need to reset the changes ... return this._store.save(this._changes, this.toJSON()); }, // ### query // // AJAX method with promise API to get records from the backend. // // It will query based on current query state (given by this.queryState) // updated by queryObj (if provided). // // Resulting RecordList are used to reset this.records and are // also returned. query: function(queryObj) { var self = this; var dfd = new Deferred(); this.trigger('query:start'); if (queryObj) { var attributes = queryObj; if (queryObj instanceof my.Query) { attributes = queryObj.toJSON(); } this.queryState.set(attributes, {silent: true}); } var actualQuery = this.queryState.toJSON(); this._store.query(actualQuery, this.toJSON()) .done(function(queryResult) { self._handleResult(queryResult); self.trigger('query:done'); dfd.resolve(self.records); }) .fail(function(args) { self.trigger('query:fail', args); dfd.reject(args); }); return dfd.promise(); }, _handleQueryResult: function(queryResult) { var self = this; self.recordCount = queryResult.total; var docs = _.map(queryResult.hits, function(hit) { var _doc = new my.Record(hit); _doc.fields = self.fields; _doc.bind('change', function(doc) { self._changes.updates.push(doc.toJSON()); }); _doc.bind('destroy', function(doc) { self._changes.deletes.push(doc.toJSON()); }); return _doc; }); self.records.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); } }, toTemplateJSON: function() { var data = this.toJSON(); data.recordCount = this.recordCount; data.fields = this.fields.toJSON(); return data; }, // ### getFieldsSummary // // Get a summary for each field in the form of a `Facet`. // // @return null as this is async function. Provides deferred/promise interface. getFieldsSummary: function() { var self = this; var query = new my.Query(); query.set({size: 0}); this.fields.each(function(field) { query.addFacet(field.id); }); var dfd = new Deferred(); this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) { if (queryResult.facets) { _.each(queryResult.facets, function(facetResult, facetId) { facetResult.id = facetId; var facet = new my.Facet(facetResult); // TODO: probably want replace rather than reset (i.e. just replace the facet with this id) self.fields.get(facetId).facets.reset(facet); }); } dfd.resolve(queryResult); }); return dfd.promise(); }, // Deprecated (as of v0.5) - use record.summary() recordSummary: function(record) { return record.summary(); }, // ### _backendFromString(backendString) // // Look up a backend module from a backend string (look in recline.Backend) _backendFromString: function(backendString) { var backend = null; if (recline && recline.Backend) { _.each(_.keys(recline.Backend), function(name) { if (name.toLowerCase() === backendString.toLowerCase()) { backend = recline.Backend[name]; } }); } return backend; } }); // ## A Record // // A single record (or row) in the dataset my.Record = Backbone.Model.extend({ constructor: function Record() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### initialize // // Create a Record // // You usually will not do this directly but will have records created by // Dataset e.g. in query method // // Certain methods require presence of a fields attribute (identical to that on Dataset) initialize: function() { _.bindAll(this, 'getFieldValue'); }, // ### getFieldValue // // For the provided Field get the corresponding rendered computed data value // for this record. // // NB: if field is undefined a default '' value will be returned getFieldValue: function(field) { var val = this.getFieldValueUnrendered(field); if (field && !_.isUndefined(field.renderer)) { val = field.renderer(val, field, this.toJSON()); } return val; }, // ### getFieldValueUnrendered // // For the provided Field get the corresponding computed data value // for this record. // // NB: if field is undefined a default '' value will be returned getFieldValueUnrendered: function(field) { if (!field) { return ''; } var val = this.get(field.id); if (field.deriver) { val = field.deriver(val, field, this); } return val; }, // ### summary // // Get a simple html summary of this record in form of key/value list summary: function(record) { var self = this; var html = '
'; this.fields.each(function(field) { if (field.id != 'id') { html += '
' + field.get('label') + ': ' + self.getFieldValue(field) + '
'; } }); html += '
'; return html; }, // Override Backbone save, fetch and destroy so they do nothing // Instead, Dataset object that created this Record should take care of // handling these changes (discovery will occur via event notifications) // WARNING: these will not persist *unless* you call save on Dataset fetch: function() {}, save: function() {}, destroy: function() { this.trigger('destroy', this); } }); // ## A Backbone collection of Records my.RecordList = Backbone.Collection.extend({ constructor: function RecordList() { Backbone.Collection.prototype.constructor.apply(this, arguments); }, model: my.Record }); // ## A Field (aka Column) on a Dataset my.Field = Backbone.Model.extend({ constructor: function Field() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### 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 (this.attributes.type.toLowerCase() in this._typeMap) { this.attributes.type = this._typeMap[this.attributes.type.toLowerCase()]; } if (options) { this.renderer = options.renderer; this.deriver = options.deriver; } if (!this.renderer) { this.renderer = this.defaultRenderers[this.get('type')]; } this.facets = new my.FacetList(); }, _typeMap: { 'text': 'string', 'double': 'number', 'float': 'number', 'numeric': 'number', 'int': 'integer', 'datetime': 'date-time', 'bool': 'boolean', 'timestamp': 'date-time', 'json': 'object' }, defaultRenderers: { object: function(val, field, doc) { return JSON.stringify(val); }, geo_point: function(val, field, doc) { return JSON.stringify(val); }, 'number': 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({ constructor: function FieldList() { Backbone.Collection.prototype.constructor.apply(this, arguments); }, model: my.Field }); // ## Query my.Query = Backbone.Model.extend({ constructor: function Query() { Backbone.Model.prototype.constructor.apply(this, arguments); }, defaults: function() { return { size: 100, from: 0, q: '', facets: {}, filters: [] }; }, _filterTemplates: { term: { type: 'term', // TODO do we need this attribute here? field: '', term: '' }, range: { type: 'range', from: '', to: '' }, geo_distance: { type: 'geo_distance', distance: 10, unit: 'km', point: { lon: 0, lat: 0 } } }, // ### addFilter(filter) // // Add a new filter specified by the filter hash and append to the list of filters // // @param filter an object specifying the filter - see _filterTemplates for examples. If only type is provided will generate a filter by cloning _filterTemplates addFilter: function(filter) { // crude deep copy var ourfilter = JSON.parse(JSON.stringify(filter)); // not fully specified so use template and over-write if (_.keys(filter).length <= 3) { ourfilter = _.defaults(ourfilter, this._filterTemplates[filter.type]); } var filters = this.get('filters'); filters.push(ourfilter); this.trigger('change:filters:new-blank'); }, replaceFilter: function(filter) { // delete filter on the same field, then add var filters = this.get('filters'); var idx = -1; _.each(this.get('filters'), function(f, key, list) { if (filter.field == f.field) { idx = key; } }); // trigger just one event (change:filters:new-blank) instead of one for remove and // one for add if (idx >= 0) { filters.splice(idx, 1); this.set({filters: filters}); } this.addFilter(filter); }, updateFilter: function(index, value) { }, // ### 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, size, silent) { 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 } }; if (!_.isUndefined(size)) { facets[fieldId].terms.size = size; } this.set({facets: facets}, {silent: true}); if (!silent) { 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); }, removeFacet: 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; } delete facets[fieldId]; this.set({facets: facets}, {silent: true}); this.trigger('facet:remove', this); }, clearFacets: function() { var facets = this.get('facets'); _.each(_.keys(facets), function(fieldId) { delete facets[fieldId]; }); this.trigger('facet:remove', this); }, // trigger a facet add; use this to trigger a single event after adding // multiple facets refreshFacets: function() { this.trigger('facet:add', this); } }); // ## A Facet (Result) my.Facet = Backbone.Model.extend({ constructor: function Facet() { Backbone.Model.prototype.constructor.apply(this, arguments); }, defaults: function() { return { _type: 'terms', total: 0, other: 0, missing: 0, terms: [] }; } }); // ## A Collection/List of Facets my.FacetList = Backbone.Collection.extend({ constructor: function FacetList() { Backbone.Collection.prototype.constructor.apply(this, arguments); }, 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); // }; }(this.recline.Model)); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { "use strict"; // ## 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}, ... ], // // options are: lines, points, lines-and-points, bars, columns // graphType: 'lines', // graphOptions: {custom [flot options]} // } // // 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.Flot = Backbone.View.extend({ 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.

\
\
\
\ ', initialize: function(options) { var self = this; this.graphColors = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"]; _.bindAll(this, 'render', 'redraw', '_toolTip', '_xaxisLabel'); this.needToRedraw = false; this.listenTo(this.model, 'change', this.render); this.listenTo(this.model.fields, 'reset add', this.render); this.listenTo(this.model.records, 'reset add', this.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.previousTooltipPoint = {x: null, y: null}; this.editor = new my.FlotControls({ model: this.model, state: this.state.toJSON() }); this.listenTo(this.editor.state, 'change', function() { self.state.set(self.editor.state.toJSON()); self.redraw(); }); this.elSidebar = this.editor.$el; }, 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'); this.$graph.on("plothover", this._toolTip); return this; }, remove: function () { this.editor.remove(); Backbone.View.prototype.remove.apply(this, arguments); }, redraw: function() { // There are 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); if ((!areWeVisible || this.model.records.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, series[0].data.length); this.plot = $.plot(this.$graph, series, options); } }, show: function() { // because we cannot redraw when hidden we may need to when becoming visible if (this.needToRedraw) { this.redraw(); } }, // infoboxes on mouse hover on points/bars etc _toolTip: function (event, pos, item) { if (item) { if (this.previousTooltipPoint.x !== item.dataIndex || this.previousTooltipPoint.y !== item.seriesIndex) { this.previousTooltipPoint.x = item.dataIndex; this.previousTooltipPoint.y = item.seriesIndex; $("#recline-flot-tooltip").remove(); var x = item.datapoint[0].toFixed(2), y = item.datapoint[1].toFixed(2); if (this.state.attributes.graphType === 'bars') { x = item.datapoint[1].toFixed(2), y = item.datapoint[0].toFixed(2); } var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { group: this.state.attributes.group, x: this._xaxisLabel(x), series: item.series.label, y: y }); // use a different tooltip location offset for bar charts var xLocation, yLocation; if (this.state.attributes.graphType === 'bars') { xLocation = item.pageX + 15; yLocation = item.pageY - 10; } else if (this.state.attributes.graphType === 'columns') { xLocation = item.pageX + 15; yLocation = item.pageY; } else { xLocation = item.pageX + 10; yLocation = item.pageY - 20; } $('
' + content + '
').css({ top: yLocation, left: xLocation }).appendTo("body").fadeIn(200); } } else { $("#recline-flot-tooltip").remove(); this.previousTooltipPoint.x = null; this.previousTooltipPoint.y = null; } }, _xaxisLabel: function (x) { if (this._groupFieldIsDateTime()) { // oddly x comes through as milliseconds *string* (rather than int // or float) so we have to reparse x = new Date(parseFloat(x)).toLocaleDateString(); } else if (this.xvaluesAreIndex) { x = parseInt(x, 10); // HACK: deal with bar graph style cases where x-axis items were strings // In this case x at this point is the index of the item in the list of // records not its actual x-axis value x = this.model.records.models[x].get(this.state.attributes.group); } return x; }, // ### getGraphOptions // // Get options for Flot Graph // // needs to be function as can depend on state // // @param typeId graphType id (lines, lines-and-points etc) // @param numPoints the number of points that will be plotted getGraphOptions: function(typeId, numPoints) { var self = this; var groupFieldIsDateTime = self._groupFieldIsDateTime(); var xaxis = {}; if (!groupFieldIsDateTime) { xaxis.tickFormatter = function (x) { // convert x to a string and make sure that it is not too long or the // tick labels will overlap // TODO: find a more accurate way of calculating the size of tick labels var label = self._xaxisLabel(x) || ""; if (typeof label !== 'string') { label = label.toString(); } if (self.state.attributes.graphType !== 'bars' && label.length > 10) { label = label.slice(0, 10) + "..."; } return label; }; } // for labels case we only want ticks at the label intervals // HACK: however we also get this case with Date fields. In that case we // could have a lot of values and so we limit to max 15 (we assume) if (this.xvaluesAreIndex) { var numTicks = Math.min(this.model.records.length, 15); var increment = this.model.records.length / numTicks; var ticks = []; for (var i=0; i \
\
\ \
\ \
\ \
\ \
\
\
\
\
\ \
\ \
\ \ ', templateSeriesEditor: ' \
\ \
\ \
\
\ ', events: { 'change form select': 'onEditorSubmit', 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries' }, initialize: function(options) { var self = this; _.bindAll(this, 'render'); this.listenTo(this.model.fields, 'reset add', this.render); this.state = new recline.Model.ObjectState(options.state); this.render(); }, render: function() { var self = this; var tmplData = this.model.toTemplateJSON(); var htmls = Mustache.render(this.template, tmplData); this.$el.html(htmls); // 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); }, // 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); this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; this.recline.View.Graph = this.recline.View.Flot; this.recline.View.GraphControls = this.recline.View.FlotControls; /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { "use strict"; // ## (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; _.bindAll(this, 'render', 'onHorizontalScroll'); this.listenTo(this.model.records, 'add reset remove', this.render); this.tempState = {}; var state = _.extend({ hiddenFields: [] }, modelEtc.state ); this.state = new recline.Model.ObjectState(state); }, events: { // does not work here so done at end of render function // 'scroll .recline-grid tbody': 'onHorizontalScroll' }, // ====================================================== // Column and row menus 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: ' \
\ \ \ \ {{#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 = this.fields.map(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 = new recline.Model.FieldList(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), 10); // if columns extend outside viewport then remainder is 0 var remainder = Math.max(fullWidth - numFields * width,0); this.fields.each(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.records.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 record. // // 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-record,
//     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.listenTo(this.model, '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) { "use strict"; // ## Map view for a Dataset using Leaflet mapping library. // // This view allows to plot gereferenced records on a map. The location // information can be provided in 2 ways: // // 1. Via a single field. This field must be either a geo_point or // [GeoJSON](http://geojson.org) object // 2. Via two fields with latitude and longitude coordinates. // // Which fields in the data these correspond to can be configured via the state // (and are guessed if no info is provided). // // 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}
//     autoZoom: true,
//     // use cluster support
//     // cluster: true = always on
//     // cluster: false = always off
//     cluster: false
//   }
// 
// // Useful attributes to know about (if e.g. customizing) // // * map: the Leaflet map (L.Map) // * features: Leaflet GeoJSON layer containing all the features (L.GeoJSON) my.Map = Backbone.View.extend({ 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: ['geojson', 'geom','the_geom','geometry','spatial','location', 'geo', 'lonlat'], initialize: function(options) { var self = this; this.visible = true; this.mapReady = false; // this will be the Leaflet L.Map object (setup below) this.map = null; var stateData = _.extend({ geomField: null, lonField: null, latField: null, autoZoom: true, cluster: false }, options.state ); this.state = new recline.Model.ObjectState(stateData); this._clusterOptions = { zoomToBoundsOnClick: true, //disableClusteringAtZoom: 10, maxClusterRadius: 80, singleMarkerMode: false, skipDuplicateAddTesting: true, animateAddingMarkers: false }; // Listen to changes in the fields this.listenTo(this.model.fields, 'change', function() { self._setupGeometryField(); self.render(); }); // Listen to changes in the records this.listenTo(this.model.records, 'add', function(doc){self.redraw('add',doc);}); this.listenTo(this.model.records, 'change', function(doc){ self.redraw('remove',doc); self.redraw('add',doc); }); this.listenTo(this.model.records, 'remove', function(doc){self.redraw('remove',doc);}); this.listenTo(this.model.records, 'reset', function(){self.redraw('reset');}); this.menu = new my.MapMenu({ model: this.model, state: this.state.toJSON() }); this.listenTo(this.menu.state, 'change', function() { self.state.set(self.menu.state.toJSON()); self.redraw(); }); this.listenTo(this.state, 'change', function() { self.redraw(); }); this.elSidebar = this.menu.$el; }, // ## Customization Functions // // The following methods are designed for overriding in order to customize // behaviour // ### infobox // // Function to create infoboxes used in popups. The default behaviour is very simple and just lists all attributes. // // Users should override this function to customize behaviour i.e. // // view = new View({...}); // view.infobox = function(record) { // ... // } infobox: function(record) { var html = ''; for (var key in record.attributes){ if (!(this.state.get('geomField') && key == this.state.get('geomField'))){ html += '
' + key + ': '+ record.attributes[key] + '
'; } } return html; }, // Options to use for the [Leaflet GeoJSON layer](http://leaflet.cloudmade.com/reference.html#geojson) // See also // // e.g. // // pointToLayer: function(feature, latLng) // onEachFeature: function(feature, layer) // // See defaults for examples geoJsonLayerOptions: { // pointToLayer function to use when creating points // // Default behaviour shown here is to create a marker using the // popupContent set on the feature properties (created via infobox function // during feature generation) // // NB: inside pointToLayer `this` will be set to point to this map view // instance (which allows e.g. this.markers to work in this default case) pointToLayer: function (feature, latlng) { var marker = new L.Marker(latlng); marker.bindPopup(feature.properties.popupContent); // this is for cluster case this.markers.addLayer(marker); return marker; }, // onEachFeature default which adds popup in onEachFeature: function(feature, layer) { if (feature.properties && feature.properties.popupContent) { layer.bindPopup(feature.properties.popupContent); } } }, // END: Customization section // ---- // ### Public: Adds the necessary elements to the page. // // Also sets up the editor fields and the map if necessary. render: function() { var self = this; var htmls = Mustache.render(this.template, this.model.toTemplateJSON()); this.$el.html(htmls); this.$map = this.$el.find('.panel.map'); this.redraw(); 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 (records) // * remove: Remove one or n features (records) // * refresh: Clear existing features and add all current records 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){ // removing ad re-adding the layer enables faster bulk loading this.map.removeLayer(this.features); this.map.removeLayer(this.markers); var countBefore = 0; this.features.eachLayer(function(){countBefore++;}); if (action == 'refresh' || action == 'reset') { this.features.clearLayers(); // recreate cluster group because of issues with clearLayer this.map.removeLayer(this.markers); this.markers = new L.MarkerClusterGroup(this._clusterOptions); this._add(this.model.records.models); } else if (action == 'add' && doc){ this._add(doc); } else if (action == 'remove' && doc){ this._remove(doc); } // this must come before zooming! // if not: errors when using e.g. circle markers like // "Cannot call method 'project' of undefined" if (this.state.get('cluster')) { this.map.addLayer(this.markers); } else { this.map.addLayer(this.features); } if (this.state.get('autoZoom')){ if (this.visible){ this._zoomToFeatures(); } else { this._zoomPending = true; } } } }, show: function() { // If the div was hidden, Leaflet needs to recalculate some sizes // to display properly if (this.map){ this.map.invalidateSize(); if (this._zoomPending && this.state.get('autoZoom')) { this._zoomToFeatures(); this._zoomPending = false; } } this.visible = true; }, hide: function() { this.visible = false; }, _geomReady: function() { return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); }, // Private: Add one or n features to the map // // For each record 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 record 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._getGeometryFromRecord(doc); if (typeof feature === 'undefined' || feature === null){ // Empty field return true; } else if (feature instanceof Object){ feature.properties = { popupContent: self.infobox(doc), // Add a reference to the model id, which will allow us to // link this Leaflet layer to a Recline doc cid: doc.cid }; try { self.features.addData(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 from the map // _remove: function(docs){ var self = this; if (!(docs instanceof Array)) docs = [docs]; _.each(docs,function(doc){ for (var key in self.features._layers){ if (self.features._layers[key].feature.properties.cid == doc.cid){ self.features.removeLayer(self.features._layers[key]); } } }); }, // Private: convert DMS coordinates to decimal // // north and east are positive, south and west are negative // _parseCoordinateString: function(coord){ if (typeof(coord) != 'string') { return(parseFloat(coord)); } var dms = coord.split(/[^-?\.\d\w]+/); var deg = 0; var m = 0; var toDeg = [1, 60, 3600]; // conversion factors for Deg, min, sec var i; for (i = 0; i < dms.length; ++i) { if (isNaN(parseFloat(dms[i]))) { continue; } deg += parseFloat(dms[i]) / toDeg[m]; m += 1; } if (coord.match(/[SW]/)) { deg = -1*deg; } return(deg); }, // Private: Return a GeoJSON geomtry extracted from the record fields // _getGeometryFromRecord: function(doc){ 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 { value = $.parseJSON(value); } catch(e) {} } if (typeof(value) === 'string') { value = value.replace('(', '').replace(')', ''); var parts = value.split(','); var lat = this._parseCoordinateString(parts[0]); var lon = this._parseCoordinateString(parts[1]); if (!isNaN(lon) && !isNaN(parseFloat(lat))) { return { "type": "Point", "coordinates": [lon, lat] }; } else { return null; } } else if (value && _.isArray(value)) { // [ lon, lat ] return { "type": "Point", "coordinates": [value[0], value[1]] }; } else if (value && value.lat) { // of form { lat: ..., lon: ...} return { "type": "Point", "coordinates": [value.lon || value.lng, value.lat] }; } // We o/w assume that 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')); lon = this._parseCoordinateString(lon); lat = this._parseCoordinateString(lat); 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(){ // 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.menu.state.set(this.state.toJSON()); } }, // 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 && bounds.getNorthEast() && bounds.getSouthWest()){ this.map.fitBounds(bounds); } else { this.map.setView([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(){ var self = this; this.map = new L.Map(this.$map.get(0)); var mapUrl = "//otile{s}-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.markers = new L.MarkerClusterGroup(this._clusterOptions); // rebind this (as needed in e.g. default case above) this.geoJsonLayerOptions.pointToLayer = _.bind( this.geoJsonLayerOptions.pointToLayer, this); this.features = new L.GeoJSON(null, this.geoJsonLayerOptions); this.map.setView([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; } }); } } }); my.MapMenu = Backbone.View.extend({ className: 'editor', template: ' \
\
\
\ \ \
\
\ \
\ \
\ \
\ \
\
\ \
\
\ \
\
\ \ \
\ \
\ ', // Define here events for UI elements events: { 'click .editor-update-map': 'onEditorSubmit', 'change .editor-field-type': 'onFieldTypeChange', 'click #editor-auto-zoom': 'onAutoZoomChange', 'click #editor-cluster': 'onClusteringChange' }, initialize: function(options) { var self = this; _.bindAll(this, 'render'); this.listenTo(this.model.fields, 'change', this.render); this.state = new recline.Model.ObjectState(options.state); this.listenTo(this.state, 'change', this.render); 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; var htmls = Mustache.render(this.template, this.model.toTemplateJSON()); this.$el.html(htmls); if (this._geomReady() && this.model.fields.length){ if (this.state.get('geomField')){ this._selectOption('editor-geom-field',this.state.get('geomField')); this.$el.find('#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')); this.$el.find('#editor-field-type-latlon').attr('checked','checked').change(); } } if (this.state.get('autoZoom')) { this.$el.find('#editor-auto-zoom').attr('checked', 'checked'); } else { this.$el.find('#editor-auto-zoom').removeAttr('checked'); } if (this.state.get('cluster')) { this.$el.find('#editor-cluster').attr('checked', 'checked'); } else { this.$el.find('#editor-cluster').removeAttr('checked'); } return this; }, _geomReady: function() { return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); }, // ## 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 (this.$el.find('#editor-field-type-geom').attr('checked')){ this.state.set({ geomField: this.$el.find('.editor-geom-field > select > option:selected').val(), lonField: null, latField: null }); } else { this.state.set({ geomField: null, lonField: this.$el.find('.editor-lon-field > select > option:selected').val(), latField: this.$el.find('.editor-lat-field > select > option:selected').val() }); } return false; }, // Public: Shows the relevant select lists depending on the location field // type selected. // onFieldTypeChange: function(e){ if (e.target.value == 'geom'){ this.$el.find('.editor-field-type-geom').show(); this.$el.find('.editor-field-type-latlon').hide(); } else { this.$el.find('.editor-field-type-geom').hide(); this.$el.find('.editor-field-type-latlon').show(); } }, onAutoZoomChange: function(e){ this.state.set({autoZoom: !this.state.get('autoZoom')}); }, onClusteringChange: function(e){ this.state.set({cluster: !this.state.get('cluster')}); }, // 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; } }); } } }); })(jQuery, recline.View); /*jshint multistr:true */ // Standard JS module setup this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { "use strict"; // ## MultiView // // Manage multiple views together along with query editor etc. Usage: // //
// var myExplorer = new recline.View.MultiView({
//   model: {{recline.Model.Dataset instance}}
//   el: {{an existing dom element}}
//   views: {{dataset views}}
//   state: {{state configuration -- see below}}
// });
// 
// // ### Parameters // // **model**: (required) recline.model.Dataset instance. // // **el**: (required) DOM element to bind to. NB: the element already // being in the DOM is important for rendering of some subviews (e.g. // Graph). // // **views**: (optional) the dataset views (Grid, Graph etc) for // MultiView to show. This is an array of view hashes. If not provided // initialize with (recline.View.)Grid, Graph, and Map views (with obvious id // and labels!). // //
// var views = [
//   {
//     id: 'grid', // used for routing
//     label: 'Grid', // used for view switcher
//     view: new recline.View.Grid({
//       model: dataset
//     })
//   },
//   {
//     id: 'graph',
//     label: 'Graph',
//     view: new recline.View.Graph({
//       model: dataset
//     })
//   }
// ];
// 
// // **sidebarViews**: (optional) the sidebar views (Filters, Fields) for // MultiView to show. This is an array of view hashes. If not provided // initialize with (recline.View.)FilterEditor and Fields views (with obvious // id and labels!). // //
// var sidebarViews = [
//   {
//     id: 'filterEditor', // used for routing
//     label: 'Filters', // used for view switcher
//     view: new recline.View.FilterEditor({
//       model: dataset
//     })
//   },
//   {
//     id: 'fieldsView',
//     label: 'Fields',
//     view: new recline.View.Fields({
//       model: dataset
//     })
//   }
// ];
// 
// // **state**: standard state config for this view. This state is slightly // special as it includes config of many of the subviews. // //
// var state = {
//     query: {dataset query state - see dataset.queryState object}
//     'view-{id1}': {view-state for this view}
//     'view-{id2}': {view-state for }
//     ...
//     // Explorer
//     currentView: id of current view (defaults to first view if not specified)
//     readOnly: (default: false) run in read-only mode
// }
// 
// // Note that at present we do *not* serialize information about the actual set // of views in use -- e.g. those specified by the views argument -- but instead // expect either that the default views are fine or that the client to have // initialized the MultiView with the relevant views themselves. my.MultiView = Backbone.View.extend({ template: ' \
\
\ \
\ \
\ {{recordCount}} records\
\ \
\
\
\
\
\ ', events: { 'click .menu-right a': '_onMenuClick', 'click .navigation a': '_onSwitchView' }, initialize: function(options) { var self = this; this._setupState(options.state); // Hash of 'page' views (i.e. those for whole page) keyed by page name if (options.views) { this.pageViews = options.views; } else { this.pageViews = [{ id: 'grid', label: 'Grid', view: new my.SlickGrid({ model: this.model, state: this.state.get('view-grid') }) }, { id: 'graph', label: 'Graph', view: new my.Graph({ model: this.model, state: this.state.get('view-graph') }) }, { id: 'map', label: 'Map', view: new my.Map({ model: this.model, state: this.state.get('view-map') }) }, { id: 'timeline', label: 'Timeline', view: new my.Timeline({ model: this.model, state: this.state.get('view-timeline') }) }]; } // Hashes of sidebar elements if(options.sidebarViews) { this.sidebarViews = options.sidebarViews; } else { this.sidebarViews = [{ id: 'filterEditor', label: 'Filters', view: new my.FilterEditor({ model: this.model }) }, { id: 'fieldsView', label: 'Fields', view: new my.Fields({ model: this.model }) }]; } // these must be called after pageViews are created this.render(); this._bindStateChanges(); this._bindFlashNotifications(); // now do updates based on state (need to come after render) if (this.state.get('readOnly')) { this.setReadOnly(); } if (this.state.get('currentView')) { this.updateNav(this.state.get('currentView')); } else { this.updateNav(this.pageViews[0].id); } this._showHideSidebar(); this.listenTo(this.model, 'query:start', function() { self.notify({loader: true, persist: true}); }); this.listenTo(this.model, 'query:done', function() { self.clearNotifications(); self.$el.find('.doc-count').text(self.model.recordCount || 'Unknown'); }); this.listenTo(this.model, 'query:fail', function(error) { self.clearNotifications(); var msg = ''; if (typeof(error) == 'string') { msg = error; } else if (typeof(error) == 'object') { if (error.title) { msg = error.title + ': '; } if (error.message) { msg += error.message; } } else { msg = 'There was an error querying the backend'; } self.notify({message: msg, category: 'error', persist: true}); }); // retrieve basic data like fields etc // note this.model and dataset returned are the same // TODO: set query state ...? this.model.queryState.set(self.state.get('query'), {silent: true}); }, setReadOnly: function() { this.$el.addClass('recline-read-only'); }, render: function() { var tmplData = this.model.toTemplateJSON(); tmplData.views = this.pageViews; tmplData.sidebarViews = this.sidebarViews; var template = Mustache.render(this.template, tmplData); this.$el.html(template); // now create and append other views var $dataViewContainer = this.$el.find('.data-view-container'); var $dataSidebar = this.$el.find('.data-view-sidebar'); // the main views _.each(this.pageViews, function(view, pageName) { view.view.render(); if (view.view.redraw) { view.view.redraw(); } $dataViewContainer.append(view.view.el); if (view.view.elSidebar) { $dataSidebar.append(view.view.elSidebar); } }); _.each(this.sidebarViews, function(view) { this['$'+view.id] = view.view.$el; $dataSidebar.append(view.view.el); }, this); this.pager = new recline.View.Pager({ model: this.model }); this.$el.find('.recline-results-info').after(this.pager.el); this.queryEditor = new recline.View.QueryEditor({ model: this.model.queryState }); this.$el.find('.query-editor-here').append(this.queryEditor.el); }, remove: function () { _.each(this.pageViews, function (view) { view.view.remove(); }); _.each(this.sidebarViews, function (view) { view.view.remove(); }); this.pager.remove(); this.queryEditor.remove(); Backbone.View.prototype.remove.apply(this, arguments); }, // hide the sidebar if empty _showHideSidebar: function() { var $dataSidebar = this.$el.find('.data-view-sidebar'); var visibleChildren = $dataSidebar.children().filter(function() { return $(this).css("display") != "none"; }).length; if (visibleChildren > 0) { $dataSidebar.show(); } else { $dataSidebar.hide(); } }, updateNav: function(pageName) { this.$el.find('.navigation a').removeClass('active'); var $el = this.$el.find('.navigation a[data-view="' + pageName + '"]'); $el.addClass('active'); // add/remove sidebars and hide inactive views _.each(this.pageViews, function(view, idx) { if (view.id === pageName) { view.view.$el.show(); if (view.view.elSidebar) { view.view.elSidebar.show(); } } else { view.view.$el.hide(); if (view.view.elSidebar) { view.view.elSidebar.hide(); } if (view.view.hide) { view.view.hide(); } } }); this._showHideSidebar(); // call view.view.show after sidebar visibility has been determined so // that views can correctly calculate their maximum width _.each(this.pageViews, function(view, idx) { if (view.id === pageName) { if (view.view.show) { view.view.show(); } } }); }, _onMenuClick: function(e) { e.preventDefault(); var action = $(e.target).attr('data-action'); this['$'+action].toggle(); this._showHideSidebar(); }, _onSwitchView: function(e) { e.preventDefault(); var viewName = $(e.target).attr('data-view'); this.updateNav(viewName); this.state.set({currentView: viewName}); }, // create a state object for this view and do the job of // // a) initializing it from both data passed in and other sources (e.g. hash url) // // b) ensure the state object is updated in responese to changes in subviews, query etc. _setupState: function(initialState) { var self = this; // get data from the query string / hash url plus some defaults var qs = my.parseHashQueryString(); var query = qs.reclineQuery; query = query ? JSON.parse(query) : self.model.queryState.toJSON(); // backwards compatability (now named view-graph but was named graph) var graphState = qs['view-graph'] || qs.graph; graphState = graphState ? JSON.parse(graphState) : {}; // now get default data + hash url plus initial state and initial our state object with it var stateData = _.extend({ query: query, 'view-graph': graphState, backend: this.model.backend.__type__, url: this.model.get('url'), dataset: this.model.toJSON(), currentView: null, readOnly: false }, initialState); this.state = new recline.Model.ObjectState(stateData); }, _bindStateChanges: function() { var self = this; // finally ensure we update our state object when state of sub-object changes so that state is always up to date this.listenTo(this.model.queryState, 'change', function() { self.state.set({query: self.model.queryState.toJSON()}); }); _.each(this.pageViews, function(pageView) { if (pageView.view.state && pageView.view.state.bind) { var update = {}; update['view-' + pageView.id] = pageView.view.state.toJSON(); self.state.set(update); self.listenTo(pageView.view.state, 'change', function() { var update = {}; update['view-' + pageView.id] = pageView.view.state.toJSON(); // had problems where change not being triggered for e.g. grid view so let's do it explicitly self.state.set(update, {silent: true}); self.state.trigger('change'); }); } }); }, _bindFlashNotifications: function() { var self = this; _.each(this.pageViews, function(pageView) { self.listenTo(pageView.view, 'recline:flash', function(flash) { self.notify(flash); }); }); }, // ### notify // // Create a notification (a div.alert in div.alert-messsages) using provided // flash object. Flash attributes (all are optional): // // * message: message to show. // * category: warning (default), success, error // * persist: if true alert is persistent, o/w hidden after 3s (default = false) // * loader: if true show loading spinner notify: function(flash) { var tmplData = _.extend({ message: 'Loading', category: 'warning', loader: false }, flash ); var _template; if (tmplData.loader) { _template = ' \
\ {{message}} \   \
'; } else { _template = ' \
× \ {{message}} \
'; } var _templated = $(Mustache.render(_template, tmplData)); _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); if (!flash.persist) { setTimeout(function() { $(_templated).fadeOut(1000, function() { $(this).remove(); }); }, 1000); } }, // ### clearNotifications // // Clear all existing notifications clearNotifications: function() { var $notifications = $('.recline-data-explorer .alert-messages .alert'); $notifications.fadeOut(1500, function() { $(this).remove(); }); } }); // ### MultiView.restore // // Restore a MultiView instance from a serialized state including the associated dataset // // This inverts the state serialization process in Multiview my.MultiView.restore = function(state) { // hack-y - restoring a memory dataset does not mean much ... (but useful for testing!) var datasetInfo; if (state.backend === 'memory') { datasetInfo = { backend: 'memory', records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}] }; } else { datasetInfo = _.extend({ url: state.url, backend: state.backend }, state.dataset ); } var dataset = new recline.Model.Dataset(datasetInfo); var explorer = new my.MultiView({ model: dataset, state: state }); return explorer; }; // ## 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() { var 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); }; })(jQuery, recline.View); /*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { "use strict"; // ## 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`. // // Additional options to drive SlickGrid grid can be given through state. // The following keys allow for customization: // * gridOptions: to add options at grid level // * columnsEditor: to add editor for editable columns // // For example: // var grid = new recline.View.SlickGrid({ // model: dataset, // el: $el, // state: { // gridOptions: { // editable: true, // enableAddRow: true // // Enable support for row delete // enabledDelRow: true, // // Enable support for row Reorder // enableReOrderRow:true, // ... // }, // columnsEditor: [ // {column: 'date', editor: Slick.Editors.Date }, // {column: 'title', editor: Slick.Editors.Text} // ] // } // }); //// NB: you need an explicit height on the element for slickgrid to work my.SlickGrid = Backbone.View.extend({ initialize: function(modelEtc) { var self = this; this.$el.addClass('recline-slickgrid'); // Template for row delete menu , change it if you don't love this.templates = { "deleterow" : 'X' }; _.bindAll(this, 'render', 'onRecordChanged'); this.listenTo(this.model.records, 'add remove reset', this.render); this.listenTo(this.model.records, 'change', this.onRecordChanged); var state = _.extend({ hiddenColumns: [], columnsOrder: [], columnsSort: {}, columnsWidth: [], columnsEditor: [], options: {}, fitColumns: false }, modelEtc.state ); this.state = new recline.Model.ObjectState(state); this._slickHandler = new Slick.EventHandler(); //add menu for new row , check if enableAddRow is set to true or not set if(this.state.get("gridOptions") && this.state.get("gridOptions").enabledAddRow != undefined && this.state.get("gridOptions").enabledAddRow == true ){ this.editor = new my.GridControl() this.elSidebar = this.editor.$el this.listenTo(this.editor.state, 'change', function(){ this.model.records.add(new recline.Model.Record()) }); } }, onRecordChanged: function(record) { // Ignore if the grid is not yet drawn if (!this.grid) { return; } // Let's find the row corresponding to the index var row_index = this.grid.getData().getModelRow( record ); this.grid.invalidateRow(row_index); this.grid.getData().updateItem(record, row_index); this.grid.render(); }, render: function() { var self = this; var options = _.extend({ enableCellNavigation: true, enableColumnReorder: true, explicitInitialization: true, syncColumnCellResize: true, forceFitColumns: this.state.get('fitColumns') }, self.state.get('gridOptions')); // We need all columns, even the hidden ones, to show on the column picker var columns = []; // custom formatter as default one escapes html // plus this way we distinguish between rendering/formatting and computed value (so e.g. sort still works ...) // row = row index, cell = cell index, value = value, columnDef = column definition, dataContext = full row values var formatter = function(row, cell, value, columnDef, dataContext) { if(columnDef.id == "del"){ return self.templates.deleterow } var field = self.model.fields.get(columnDef.id); if (field.renderer) { return field.renderer(value, field, dataContext); } else { return value } }; // we need to be sure that user is entering a valid input , for exemple if // field is date type and field.format ='YY-MM-DD', we should be sure that // user enter a correct value var validator = function(field) { return function(value){ if (field.type == "date" && isNaN(Date.parse(value))){ return { valid: false, msg: "A date is required, check field field-date-format" }; } else { return {valid: true, msg :null } } } }; // Add column for row reorder support if (this.state.get("gridOptions") && this.state.get("gridOptions").enableReOrderRow == true) { columns.push({ id: "#", name: "", width: 22, behavior: "selectAndMove", selectable: false, resizable: false, cssClass: "recline-cell-reorder" }) } // Add column for row delete support if (this.state.get("gridOptions") && this.state.get("gridOptions").enabledDelRow == true) { columns.push({ id: 'del', name: '', field: 'del', sortable: true, width: 38, formatter: formatter, validator:validator }) } _.each(this.model.fields.toJSON(),function(field){ var column = { id: field.id, name: field.label, field: field.id, sortable: true, minWidth: 80, formatter: formatter, validator:validator(field) }; var widthInfo = _.find(self.state.get('columnsWidth'),function(c){return c.column === field.id;}); if (widthInfo){ column.width = widthInfo.width; } var editInfo = _.find(self.state.get('columnsEditor'),function(c){return c.column === field.id;}); if (editInfo){ column.editor = editInfo.editor; } else { // guess editor type var typeToEditorMap = { 'string': Slick.Editors.LongText, 'integer': Slick.Editors.IntegerEditor, 'number': Slick.Editors.Text, // TODO: need a way to ensure we format date in the right way // Plus what if dates are in distant past or future ... (?) // 'date': Slick.Editors.DateEditor, 'date': Slick.Editors.Text, 'boolean': Slick.Editors.YesNoSelectEditor // TODO: (?) percent ... }; if (field.type in typeToEditorMap) { column.editor = typeToEditorMap[field.type] } else { column.editor = Slick.Editors.LongText; } } columns.push(column); }); // Restrict the visible columns var visibleColumns = _.filter(columns, 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') && this.state.get('columnsOrder').length > 0) { visibleColumns = visibleColumns.sort(function(a,b){ return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1; }); columns = columns.sort(function(a,b){ return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1; }); } // 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); // Transform a model object into a row function toRow(m) { var row = {}; self.model.fields.each(function(field) { var render = ""; //when adding row from slickgrid the field value is undefined if(!_.isUndefined(m.getFieldValueUnrendered(field))){ render =m.getFieldValueUnrendered(field) } row[field.id] = render }); return row; } function RowSet() { var models = []; var rows = []; this.push = function(model, row) { models.push(model); rows.push(row); }; this.getLength = function() {return rows.length; }; this.getItem = function(index) {return rows[index];}; this.getItemMetadata = function(index) {return {};}; this.getModel = function(index) {return models[index];}; this.getModelRow = function(m) {return _.indexOf(models, m);}; this.updateItem = function(m,i) { rows[i] = toRow(m); models[i] = m; }; } var data = new RowSet(); this.model.records.each(function(doc){ data.push(doc, toRow(doc)); }); this.grid = new Slick.Grid(this.el, data, visibleColumns, options); // Column sorting var sortInfo = this.model.queryState.get('sort'); if (sortInfo){ var column = sortInfo[0].field; var sortAsc = sortInfo[0].order !== 'desc'; this.grid.setSortColumn(column, sortAsc); } if (this.state.get("gridOptions") && this.state.get("gridOptions").enableReOrderRow) { this._setupRowReordering(); } this._slickHandler.subscribe(this.grid.onSort, function(e, args){ var order = (args.sortAsc) ? 'asc':'desc'; var sort = [{ field: args.sortCol.field, order: order }]; self.model.query({sort: sort}); }); this._slickHandler.subscribe(this.grid.onColumnsReordered, 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}); }); this._slickHandler.subscribe(this.grid.onCellChange, function (e, args) { // We need to change the model associated value var grid = args.grid; var model = data.getModel(args.row); var field = grid.getColumns()[args.cell].id; var v = {}; v[field] = args.item[field]; model.set(v); }); this._slickHandler.subscribe(this.grid.onClick,function(e, args){ //try catch , because this fail in qunit , but no //error on browser. try{e.preventDefault()}catch(e){} // The cell of grid that handle row delete is The first cell (0) if // The grid ReOrder is not present ie enableReOrderRow == false // else it is The the second cell (1) , because The 0 is now cell // that handle row Reoder. var cell =0 if(self.state.get("gridOptions") && self.state.get("gridOptions").enableReOrderRow != undefined && self.state.get("gridOptions").enableReOrderRow == true ){ cell =1 } if (args.cell == cell && self.state.get("gridOptions").enabledDelRow == true){ // We need to delete the associated model var model = data.getModel(args.row); model.destroy() } }) ; 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; }, // Row reordering support based on // https://github.com/mleibman/SlickGrid/blob/gh-pages/examples/example9-row-reordering.html _setupRowReordering: function() { var self = this; self.grid.setSelectionModel(new Slick.RowSelectionModel()); var moveRowsPlugin = new Slick.RowMoveManager({ cancelEditOnDrag: true }); moveRowsPlugin.onBeforeMoveRows.subscribe(function (e, data) { for (var i = 0; i < data.rows.length; i++) { // no point in moving before or after itself if (data.rows[i] == data.insertBefore || data.rows[i] == data.insertBefore - 1) { e.stopPropagation(); return false; } } return true; }); moveRowsPlugin.onMoveRows.subscribe(function (e, args) { var extractedRows = [], left, right; var rows = args.rows; var insertBefore = args.insertBefore; var data = self.model.records.toJSON() left = data.slice(0, insertBefore); right= data.slice(insertBefore, data.length); rows.sort(function(a,b) { return a-b; }); for (var i = 0; i < rows.length; i++) { extractedRows.push(data[rows[i]]); } rows.reverse(); for (var i = 0; i < rows.length; i++) { var row = rows[i]; if (row < insertBefore) { left.splice(row, 1); } else { right.splice(row - insertBefore, 1); } } data = left.concat(extractedRows.concat(right)); var selectedRows = []; for (var i = 0; i < rows.length; i++) selectedRows.push(left.length + i); self.model.records.reset(data) }); //register The plugin to handle row Reorder if(this.state.get("gridOptions") && this.state.get("gridOptions").enableReOrderRow) { self.grid.registerPlugin(moveRowsPlugin); } }, remove: function () { this._slickHandler.unsubscribeAll(); Backbone.View.prototype.remove.apply(this, arguments); }, 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 (!this.rendered){ if (!this.grid){ this.render(); } this.grid.init(); this.rendered = true; } this.visible = true; }, hide: function() { this.visible = false; } }); // Add new grid Control to display a new row add menu bouton // It display a simple side-bar menu ,for user to add new // row to grid my.GridControl= Backbone.View.extend({ className: "recline-row-add", // Template for row edit menu , change it if you don't love template: '

Add row

', initialize: function(options){ var self = this; _.bindAll(this, 'render'); this.state = new recline.Model.ObjectState(); this.render(); }, render: function() { var self = this; this.$el.html(this.template) }, events : { "click .recline-row-add" : "addNewRow" }, addNewRow : function(e){ e.preventDefault() this.state.trigger("change") } }); })(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 = $('