4444 lines
136 KiB
JavaScript
4444 lines
136 KiB
JavaScript
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; i<n; i++)
|
||
if (i in this && this[i]===find)
|
||
return i;
|
||
return -1;
|
||
};
|
||
}
|
||
if (!('lastIndexOf' in Array.prototype)) {
|
||
Array.prototype.lastIndexOf= function(find, i /*opt*/) {
|
||
if (i===undefined) i= this.length-1;
|
||
if (i<0) i+= this.length;
|
||
if (i>this.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; i<n; i++)
|
||
if (i in this)
|
||
action.call(that, this[i], i, this);
|
||
};
|
||
}
|
||
if (!('map' in Array.prototype)) {
|
||
Array.prototype.map= function(mapper, that /*opt*/) {
|
||
var other= new Array(this.length);
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this)
|
||
other[i]= mapper.call(that, this[i], i, this);
|
||
return other;
|
||
};
|
||
}
|
||
if (!('filter' in Array.prototype)) {
|
||
Array.prototype.filter= function(filter, that /*opt*/) {
|
||
var other= [], v;
|
||
for (var i=0, n= this.length; i<n; i++)
|
||
if (i in this && filter.call(that, v= this[i], i, this))
|
||
other.push(v);
|
||
return other;
|
||
};
|
||
}
|
||
if (!('every' in Array.prototype)) {
|
||
Array.prototype.every= function(tester, that /*opt*/) {
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this && !tester.call(that, this[i], i, this))
|
||
return false;
|
||
return true;
|
||
};
|
||
}
|
||
if (!('some' in Array.prototype)) {
|
||
Array.prototype.some= function(tester, that /*opt*/) {
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this && tester.call(that, this[i], i, this))
|
||
return true;
|
||
return false;
|
||
};
|
||
}// # Recline Backbone Models
|
||
this.recline = this.recline || {};
|
||
this.recline.Model = this.recline.Model || {};
|
||
|
||
(function(my) {
|
||
"use strict";
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## <a id="dataset">Dataset</a>
|
||
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 id="record">A Record</a>
|
||
//
|
||
// 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 = '<div class="recline-record-summary">';
|
||
this.fields.each(function(field) {
|
||
if (field.id != 'id') {
|
||
html += '<div class="' + field.id + '"><strong>' + field.get('label') + '</strong>: ' + self.getFieldValue(field) + '</div>';
|
||
}
|
||
});
|
||
html += '</div>';
|
||
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 id="field">A Field (aka Column) on a Dataset</a>
|
||
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, '<a href="$1">$1</a>');
|
||
}
|
||
return val;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
my.FieldList = Backbone.Collection.extend({
|
||
constructor: function FieldList() {
|
||
Backbone.Collection.prototype.constructor.apply(this, arguments);
|
||
},
|
||
model: my.Field
|
||
});
|
||
|
||
// ## <a id="query">Query</a>
|
||
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 <http://www.elasticsearch.org/guide/reference/api/search/facets/>
|
||
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 id="facet">A Facet (Result)</a>
|
||
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: ' \
|
||
<div class="recline-flot"> \
|
||
<div class="panel graph" style="display: block;"> \
|
||
<div class="js-temp-notice alert alert-warning alert-block"> \
|
||
<h3 class="alert-heading">Hey there!</h3> \
|
||
<p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \
|
||
<p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
|
||
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;
|
||
}
|
||
|
||
$('<div id="recline-flot-tooltip">' + content + '</div>').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<numTicks; i++) {
|
||
ticks.push(parseInt(i*increment, 10));
|
||
}
|
||
xaxis.ticks = ticks;
|
||
} else if (groupFieldIsDateTime) {
|
||
xaxis.mode = 'time';
|
||
}
|
||
|
||
var yaxis = {};
|
||
yaxis.autoscale = true;
|
||
yaxis.autoscaleMargin = 0.02;
|
||
|
||
var legend = {};
|
||
legend.position = 'ne';
|
||
|
||
var grid = {};
|
||
grid.hoverable = true;
|
||
grid.clickable = true;
|
||
grid.borderColor = "#aaaaaa";
|
||
grid.borderWidth = 1;
|
||
|
||
var optionsPerGraphType = {
|
||
lines: {
|
||
legend: legend,
|
||
colors: this.graphColors,
|
||
lines: { show: true },
|
||
xaxis: xaxis,
|
||
yaxis: yaxis,
|
||
grid: grid
|
||
},
|
||
points: {
|
||
legend: legend,
|
||
colors: this.graphColors,
|
||
points: { show: true, hitRadius: 5 },
|
||
xaxis: xaxis,
|
||
yaxis: yaxis,
|
||
grid: grid
|
||
},
|
||
'lines-and-points': {
|
||
legend: legend,
|
||
colors: this.graphColors,
|
||
points: { show: true, hitRadius: 5 },
|
||
lines: { show: true },
|
||
xaxis: xaxis,
|
||
yaxis: yaxis,
|
||
grid: grid
|
||
},
|
||
bars: {
|
||
legend: legend,
|
||
colors: this.graphColors,
|
||
lines: { show: false },
|
||
xaxis: yaxis,
|
||
yaxis: xaxis,
|
||
grid: grid,
|
||
bars: {
|
||
show: true,
|
||
horizontal: true,
|
||
shadowSize: 0,
|
||
align: 'center',
|
||
barWidth: 0.8
|
||
}
|
||
},
|
||
columns: {
|
||
legend: legend,
|
||
colors: this.graphColors,
|
||
lines: { show: false },
|
||
xaxis: xaxis,
|
||
yaxis: yaxis,
|
||
grid: grid,
|
||
bars: {
|
||
show: true,
|
||
horizontal: false,
|
||
shadowSize: 0,
|
||
align: 'center',
|
||
barWidth: 0.8
|
||
}
|
||
}
|
||
};
|
||
|
||
if (self.state.get('graphOptions')) {
|
||
return _.extend(optionsPerGraphType[typeId],
|
||
self.state.get('graphOptions'));
|
||
} else {
|
||
return optionsPerGraphType[typeId];
|
||
}
|
||
},
|
||
|
||
_groupFieldIsDateTime: function() {
|
||
var xfield = this.model.fields.get(this.state.attributes.group);
|
||
var xtype = xfield.get('type');
|
||
var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time');
|
||
return isDateTime;
|
||
},
|
||
|
||
createSeries: function() {
|
||
var self = this;
|
||
self.xvaluesAreIndex = false;
|
||
var series = [];
|
||
var xfield = self.model.fields.get(self.state.attributes.group);
|
||
var isDateTime = self._groupFieldIsDateTime();
|
||
|
||
_.each(this.state.attributes.series, function(field) {
|
||
var points = [];
|
||
var fieldLabel = self.model.fields.get(field).get('label');
|
||
|
||
if (isDateTime){
|
||
var cast = function(x){
|
||
var _date = moment(String(x));
|
||
if (_date.isValid()) {
|
||
x = _date.toDate().getTime();
|
||
}
|
||
return x
|
||
}
|
||
} else {
|
||
var raw = _.map(self.model.records.models,
|
||
function(doc, index){
|
||
return doc.getFieldValueUnrendered(xfield)
|
||
});
|
||
|
||
if (_.all(raw, function(x){ return !isNaN(parseFloat(x)) })){
|
||
var cast = function(x){ return parseFloat(x) }
|
||
} else {
|
||
self.xvaluesAreIndex = true
|
||
}
|
||
}
|
||
|
||
_.each(self.model.records.models, function(doc, index) {
|
||
if(self.xvaluesAreIndex){
|
||
var x = index;
|
||
}else{
|
||
var x = cast(doc.getFieldValueUnrendered(xfield));
|
||
}
|
||
|
||
var yfield = self.model.fields.get(field);
|
||
var y = parseFloat(doc.getFieldValueUnrendered(yfield));
|
||
|
||
if (self.state.attributes.graphType == 'bars') {
|
||
points.push([y, x]);
|
||
} else {
|
||
points.push([x, y]);
|
||
}
|
||
});
|
||
series.push({
|
||
data: points,
|
||
label: fieldLabel,
|
||
hoverable: true
|
||
});
|
||
});
|
||
return series;
|
||
}
|
||
});
|
||
|
||
my.FlotControls = Backbone.View.extend({
|
||
className: "editor",
|
||
template: ' \
|
||
<div class="editor"> \
|
||
<form class="form-stacked"> \
|
||
<div class="clearfix"> \
|
||
<div class="form-group"> \
|
||
<label>Graph Type</label> \
|
||
<div class="input editor-type"> \
|
||
<select class="form-control"> \
|
||
<option value="lines-and-points">Lines and Points</option> \
|
||
<option value="lines">Lines</option> \
|
||
<option value="points">Points</option> \
|
||
<option value="bars">Bars</option> \
|
||
<option value="columns">Columns</option> \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
<div class="form-group"> \
|
||
<label>Group Column (Axis 1)</label> \
|
||
<div class="input editor-group"> \
|
||
<select class="form-control"> \
|
||
<option value="">Please choose ...</option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-series-group"> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-buttons"> \
|
||
<button class="btn btn-default editor-add">Add Series</button> \
|
||
</div> \
|
||
<div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
|
||
<button class="editor-save">Save</button> \
|
||
<input type="hidden" class="editor-id" value="chart-1" /> \
|
||
</div> \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
templateSeriesEditor: ' \
|
||
<div class="editor-series js-series-{{seriesIndex}}"> \
|
||
<div class="form-group"> \
|
||
<label>Series <span>{{seriesName}} (Axis 2)</span> \
|
||
[<a href="#remove" class="action-remove-series">Remove</a>] \
|
||
</label> \
|
||
<div class="input"> \
|
||
<select class="form-control"> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
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: ' \
|
||
<div class="table-container"> \
|
||
<table class="recline-grid table-striped table-condensed" cellspacing="0"> \
|
||
<thead class="fixed-header"> \
|
||
<tr> \
|
||
{{#fields}} \
|
||
<th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;" title="{{label}}"> \
|
||
<span class="column-header-name">{{label}}</span> \
|
||
</th> \
|
||
{{/fields}} \
|
||
<th class="last-header" style="width: {{lastHeaderWidth}}px; max-width: {{lastHeaderWidth}}px; min-width: {{lastHeaderWidth}}px; padding: 0; margin: 0;"></th> \
|
||
</tr> \
|
||
</thead> \
|
||
<tbody class="scroll-content"></tbody> \
|
||
</table> \
|
||
</div> \
|
||
',
|
||
|
||
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 = $('<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 = $("<div style='position:absolute; top:-10000px; left:-10000px; width:100px; height:100px; overflow:scroll;'></div>").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:
|
||
//
|
||
// <pre>
|
||
// var row = new GridRow({
|
||
// model: dataset-record,
|
||
// el: dom-element,
|
||
// fields: mydatasets.fields // a FieldList object
|
||
// });
|
||
// </pre>
|
||
my.GridRow = Backbone.View.extend({
|
||
initialize: function(initData) {
|
||
_.bindAll(this, 'render');
|
||
this._fields = initData.fields;
|
||
this.listenTo(this.model, 'change', this.render);
|
||
},
|
||
|
||
template: ' \
|
||
{{#cells}} \
|
||
<td data-field="{{field}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;"> \
|
||
<div class="data-table-cell-content"> \
|
||
<a href="javascript:{}" class="data-table-cell-edit" title="Edit this cell"> </a> \
|
||
<div class="data-table-cell-value">{{{value}}}</div> \
|
||
</div> \
|
||
</td> \
|
||
{{/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: ' \
|
||
<div class="menu-container data-table-cell-editor"> \
|
||
<textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
|
||
<div id="data-table-cell-editor-actions"> \
|
||
<div class="data-table-cell-editor-action"> \
|
||
<button class="okButton btn primary">Update</button> \
|
||
<button class="cancelButton btn danger">Cancel</button> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
|
||
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:
|
||
//
|
||
// <pre>
|
||
// {
|
||
// // 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
|
||
// }
|
||
// </pre>
|
||
//
|
||
// 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: ' \
|
||
<div class="recline-map"> \
|
||
<div class="panel map"></div> \
|
||
</div> \
|
||
',
|
||
|
||
// 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 = this.$el.is(':visible');
|
||
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 += '<div><strong>' + key + '</strong>: '+ record.attributes[key] + '</div>';
|
||
}
|
||
}
|
||
return html;
|
||
},
|
||
|
||
// Options to use for the [Leaflet GeoJSON layer](http://leaflet.cloudmade.com/reference.html#geojson)
|
||
// See also <http://leaflet.cloudmade.com/examples/geojson.html>
|
||
//
|
||
// 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.geometry.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 = "http://otile{s}-s.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
|
||
var osmAttribution = 'Map data © 2011 OpenStreetMap contributors, Tiles Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png">';
|
||
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: ' \
|
||
<form class="form-stacked"> \
|
||
<div class="clearfix"> \
|
||
<div class="editor-field-type"> \
|
||
<label class="radio"> \
|
||
<input type="radio" id="editor-field-type-latlon" name="editor-field-type" value="latlon" checked="checked"/> \
|
||
Latitude / Longitude fields</label> \
|
||
<label class="radio"> \
|
||
<input type="radio" id="editor-field-type-geom" name="editor-field-type" value="geom" /> \
|
||
GeoJSON field</label> \
|
||
</div> \
|
||
<div class="editor-field-type-latlon"> \
|
||
<label>Latitude field</label> \
|
||
<div class="input editor-lat-field"> \
|
||
<select class="form-control"> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
<label>Longitude field</label> \
|
||
<div class="input editor-lon-field"> \
|
||
<select class="form-control"> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-field-type-geom" style="display:none"> \
|
||
<label>Geometry field (GeoJSON)</label> \
|
||
<div class="input editor-geom-field"> \
|
||
<select class="form-control"> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-buttons"> \
|
||
<button class="btn btn-default editor-update-map">Update</button> \
|
||
</div> \
|
||
<div class="editor-options" > \
|
||
<label class="checkbox"> \
|
||
<input type="checkbox" id="editor-auto-zoom" value="autozoom" checked="checked" /> \
|
||
Auto zoom to features</label> \
|
||
<label class="checkbox"> \
|
||
<input type="checkbox" id="editor-cluster" value="cluster"/> \
|
||
Cluster markers</label> \
|
||
</div> \
|
||
<input type="hidden" class="editor-id" value="map-1" /> \
|
||
</form> \
|
||
',
|
||
|
||
// 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:
|
||
//
|
||
// <pre>
|
||
// var myExplorer = new recline.View.MultiView({
|
||
// model: {{recline.Model.Dataset instance}}
|
||
// el: {{an existing dom element}}
|
||
// views: {{dataset views}}
|
||
// state: {{state configuration -- see below}}
|
||
// });
|
||
// </pre>
|
||
//
|
||
// ### 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!).
|
||
//
|
||
// <pre>
|
||
// 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
|
||
// })
|
||
// }
|
||
// ];
|
||
// </pre>
|
||
//
|
||
// **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!).
|
||
//
|
||
// <pre>
|
||
// 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
|
||
// })
|
||
// }
|
||
// ];
|
||
// </pre>
|
||
//
|
||
// **state**: standard state config for this view. This state is slightly
|
||
// special as it includes config of many of the subviews.
|
||
//
|
||
// <pre>
|
||
// 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
|
||
// }
|
||
// </pre>
|
||
//
|
||
// 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: ' \
|
||
<div class="recline-data-explorer"> \
|
||
<div class="alert-messages"></div> \
|
||
\
|
||
<div class="header clearfix"> \
|
||
<div class="navigation"> \
|
||
<div class="btn-group" data-toggle="buttons-radio"> \
|
||
{{#views}} \
|
||
<button href="#{{id}}" data-view="{{id}}" class="btn btn-default">{{label}}</button> \
|
||
{{/views}} \
|
||
</div> \
|
||
</div> \
|
||
<div class="recline-results-info"> \
|
||
<span class="doc-count">{{recordCount}}</span> records\
|
||
</div> \
|
||
<div class="menu-right"> \
|
||
<div class="btn-group" data-toggle="buttons-checkbox"> \
|
||
{{#sidebarViews}} \
|
||
<button href="#" data-action="{{id}}" class="btn btn-default">{{label}}</button> \
|
||
{{/sidebarViews}} \
|
||
</div> \
|
||
</div> \
|
||
<div class="query-editor-here" style="display:inline;"></div> \
|
||
</div> \
|
||
<div class="data-view-sidebar"></div> \
|
||
<div class="data-view-container"></div> \
|
||
</div> \
|
||
',
|
||
events: {
|
||
'click .menu-right button': '_onMenuClick',
|
||
'click .navigation button': '_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 button').removeClass('active');
|
||
var $el = this.$el.find('.navigation button[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 = ' \
|
||
<div class="alert alert-info alert-loader"> \
|
||
{{message}} \
|
||
<span class="notification-loader"> </span> \
|
||
</div>';
|
||
} else {
|
||
_template = ' \
|
||
<div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
|
||
{{message}} \
|
||
</div>';
|
||
}
|
||
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" : '<button href="#" class="recline-row-delete btn btn-default" title="Delete row">X</button>'
|
||
};
|
||
|
||
_.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
|
||
})
|
||
}
|
||
|
||
function sanitizeFieldName(name) {
|
||
var sanitized = $(name).text();
|
||
return (name !== sanitized && sanitized !== '') ? sanitized : name;
|
||
}
|
||
|
||
_.each(this.model.fields.toJSON(),function(field){
|
||
var column = {
|
||
id: field.id,
|
||
name: sanitizeFieldName(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: '<h1><button href="#" class="recline-row-add btn btn-default">Add row</button></h1>',
|
||
|
||
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 = $('<ul class="dropdown-menu slick-contextmenu" style="display:none;position:absolute;z-index:20;" />').appendTo(document.body);
|
||
|
||
$menu.bind('mouseleave', function (e) {
|
||
$(this).fadeOut(options.fadeSpeed);
|
||
});
|
||
$menu.bind('click', updateColumn);
|
||
|
||
}
|
||
|
||
function handleHeaderContextMenu(e, args) {
|
||
e.preventDefault();
|
||
$menu.empty();
|
||
columnCheckboxes = [];
|
||
|
||
var $li, $input;
|
||
for (var i = 0; i < columns.length; i++) {
|
||
$li = $('<li />').appendTo($menu);
|
||
$input = $('<input type="checkbox" />').data('column-id', columns[i].id).attr('id','slick-column-vis-'+columns[i].id);
|
||
columnCheckboxes.push($input);
|
||
|
||
if (grid.getColumnIndex(columns[i].id) !== null) {
|
||
$input.attr('checked', 'checked');
|
||
}
|
||
$input.appendTo($li);
|
||
$('<label />')
|
||
.text(columns[i].name)
|
||
.attr('for','slick-column-vis-'+columns[i].id)
|
||
.appendTo($li);
|
||
}
|
||
$('<li/>').addClass('divider').appendTo($menu);
|
||
$li = $('<li />').data('option', 'autoresize').appendTo($menu);
|
||
$input = $('<input type="checkbox" />').data('option', 'autoresize').attr('id','slick-option-autoresize');
|
||
$input.appendTo($li);
|
||
$('<label />')
|
||
.text('Force fit columns')
|
||
.attr('for','slick-option-autoresize')
|
||
.appendTo($li);
|
||
if (grid.getOptions().forceFitColumns) {
|
||
$input.attr('checked', 'checked');
|
||
}
|
||
|
||
$menu.css('top', e.pageY - 10)
|
||
.css('left', e.pageX - 10)
|
||
.fadeIn(options.fadeSpeed);
|
||
}
|
||
|
||
function updateColumn(e) {
|
||
var checkbox;
|
||
|
||
if ($(e.target).data('option') === 'autoresize') {
|
||
var checked;
|
||
if ($(e.target).is('li')){
|
||
checkbox = $(e.target).find('input').first();
|
||
checked = !checkbox.is(':checked');
|
||
checkbox.attr('checked',checked);
|
||
} else {
|
||
checked = e.target.checked;
|
||
}
|
||
|
||
if (checked) {
|
||
grid.setOptions({forceFitColumns:true});
|
||
grid.autosizeColumns();
|
||
} else {
|
||
grid.setOptions({forceFitColumns:false});
|
||
}
|
||
options.state.set({fitColumns:checked});
|
||
return;
|
||
}
|
||
|
||
if (($(e.target).is('li') && !$(e.target).hasClass('divider')) ||
|
||
$(e.target).is('input')) {
|
||
if ($(e.target).is('li')){
|
||
checkbox = $(e.target).find('input').first();
|
||
checkbox.attr('checked',!checkbox.is(':checked'));
|
||
}
|
||
var visibleColumns = [];
|
||
var hiddenColumnsIds = [];
|
||
$.each(columnCheckboxes, function (i, e) {
|
||
if ($(this).is(':checked')) {
|
||
visibleColumns.push(columns[i]);
|
||
} else {
|
||
hiddenColumnsIds.push(columns[i].id);
|
||
}
|
||
});
|
||
|
||
if (!visibleColumns.length) {
|
||
$(e.target).attr('checked', 'checked');
|
||
return;
|
||
}
|
||
|
||
grid.setColumns(visibleColumns);
|
||
options.state.set({hiddenColumns:hiddenColumnsIds});
|
||
}
|
||
}
|
||
init();
|
||
}
|
||
|
||
// Slick.Controls.ColumnPicker
|
||
$.extend(true, window, {
|
||
Slick: {
|
||
Controls: {
|
||
ColumnPicker: SlickColumnPicker
|
||
}
|
||
}
|
||
});
|
||
|
||
})(jQuery);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// turn off unnecessary logging from VMM Timeline
|
||
if (typeof VMM !== 'undefined') {
|
||
VMM.debug = false;
|
||
}
|
||
|
||
// ## Timeline
|
||
//
|
||
// Timeline view using http://timeline.verite.co/
|
||
my.Timeline = Backbone.View.extend({
|
||
template: ' \
|
||
<div class="recline-timeline"> \
|
||
<div id="vmm-timeline-id"></div> \
|
||
</div> \
|
||
',
|
||
|
||
// These are the default (case-insensitive) names of field that are used if found.
|
||
// If not found, the user will need to define these fields on initialization
|
||
startFieldNames: ['date','startdate', 'start', 'start-date'],
|
||
endFieldNames: ['end','endDate'],
|
||
elementId: '#vmm-timeline-id',
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
this.timeline = new VMM.Timeline(this.elementId);
|
||
this._timelineIsInitialized = false;
|
||
this.listenTo(this.model.fields, 'reset', function() {
|
||
self._setupTemporalField();
|
||
});
|
||
this.listenTo(this.model.records, 'all', function() {
|
||
self.reloadData();
|
||
});
|
||
var stateData = _.extend({
|
||
startField: null,
|
||
endField: null,
|
||
// by default timelinejs (and browsers) will parse ambiguous dates in US format (mm/dd/yyyy)
|
||
// set to true to interpret dd/dd/dddd as dd/mm/yyyy
|
||
nonUSDates: false,
|
||
timelineJSOptions: {}
|
||
},
|
||
options.state
|
||
);
|
||
this.state = new recline.Model.ObjectState(stateData);
|
||
this._setupTemporalField();
|
||
},
|
||
|
||
render: function() {
|
||
var tmplData = {};
|
||
var htmls = Mustache.render(this.template, tmplData);
|
||
this.$el.html(htmls);
|
||
// can only call _initTimeline once view in DOM as Timeline uses $
|
||
// internally to look up element
|
||
if ($(this.elementId).length > 0) {
|
||
this._initTimeline();
|
||
}
|
||
},
|
||
|
||
show: function() {
|
||
// only call _initTimeline once view in DOM as Timeline uses $ internally to look up element
|
||
if (this._timelineIsInitialized === false) {
|
||
this._initTimeline();
|
||
}
|
||
},
|
||
|
||
_initTimeline: function() {
|
||
var data = this._timelineJSON();
|
||
var config = this.state.get("timelineJSOptions");
|
||
config.id = this.elementId;
|
||
this.timeline.init(config, data);
|
||
this._timelineIsInitialized = true
|
||
},
|
||
|
||
reloadData: function() {
|
||
if (this._timelineIsInitialized) {
|
||
var data = this._timelineJSON();
|
||
this.timeline.reload(data);
|
||
}
|
||
},
|
||
|
||
// Convert record to JSON for timeline
|
||
//
|
||
// Designed to be overridden in client apps
|
||
convertRecord: function(record, fields) {
|
||
return this._convertRecord(record, fields);
|
||
},
|
||
|
||
// Internal method to generate a Timeline formatted entry
|
||
_convertRecord: function(record, fields) {
|
||
var start = this._parseDate(record.get(this.state.get('startField')));
|
||
var end = this._parseDate(record.get(this.state.get('endField')));
|
||
if (start) {
|
||
var tlEntry = {
|
||
"startDate": start,
|
||
"endDate": end,
|
||
"headline": String(record.get('title') || ''),
|
||
"text": record.get('description') || record.summary(),
|
||
"tag": record.get('tags')
|
||
};
|
||
return tlEntry;
|
||
} else {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
_timelineJSON: function() {
|
||
var self = this;
|
||
var out = {
|
||
'timeline': {
|
||
'type': 'default',
|
||
'headline': '',
|
||
'date': [
|
||
]
|
||
}
|
||
};
|
||
this.model.records.each(function(record) {
|
||
var newEntry = self.convertRecord(record, self.fields);
|
||
if (newEntry) {
|
||
out.timeline.date.push(newEntry);
|
||
}
|
||
});
|
||
// if no entries create a placeholder entry to prevent Timeline crashing with error
|
||
if (out.timeline.date.length === 0) {
|
||
var tlEntry = {
|
||
"startDate": '2000,1,1',
|
||
"headline": 'No data to show!'
|
||
};
|
||
out.timeline.date.push(tlEntry);
|
||
}
|
||
return out;
|
||
},
|
||
|
||
// convert dates into a format TimelineJS will handle
|
||
// TimelineJS does not document this at all so combo of read the code +
|
||
// trial and error
|
||
// Summary (AFAICt):
|
||
// Preferred: [-]yyyy[,mm,dd,hh,mm,ss]
|
||
// Supported: mm/dd/yyyy
|
||
_parseDate: function(date) {
|
||
if (!date) {
|
||
return null;
|
||
}
|
||
var out = $.trim(date);
|
||
out = out.replace(/(\d)th/g, '$1');
|
||
out = out.replace(/(\d)st/g, '$1');
|
||
out = $.trim(out);
|
||
if (out.match(/\d\d\d\d-\d\d-\d\d(T.*)?/)) {
|
||
out = out.replace(/-/g, ',').replace('T', ',').replace(':',',');
|
||
}
|
||
if (out.match(/\d\d-\d\d-\d\d.*/)) {
|
||
out = out.replace(/-/g, '/');
|
||
}
|
||
if (this.state.get('nonUSDates')) {
|
||
var parts = out.match(/(\d\d)\/(\d\d)\/(\d\d.*)/);
|
||
if (parts) {
|
||
out = [parts[2], parts[1], parts[3]].join('/');
|
||
}
|
||
}
|
||
return out;
|
||
},
|
||
|
||
_setupTemporalField: function() {
|
||
this.state.set({
|
||
startField: this._checkField(this.startFieldNames),
|
||
endField: this._checkField(this.endFieldNames)
|
||
});
|
||
},
|
||
|
||
_checkField: function(possibleFieldNames) {
|
||
var modelFieldNames = this.model.fields.pluck('id');
|
||
for (var i = 0; i < possibleFieldNames.length; i++){
|
||
for (var j = 0; j < modelFieldNames.length; j++){
|
||
if (modelFieldNames[j].toLowerCase() == possibleFieldNames[i].toLowerCase())
|
||
return modelFieldNames[j];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
// ## FacetViewer
|
||
//
|
||
// Widget for displaying facets
|
||
//
|
||
// Usage:
|
||
//
|
||
// var viewer = new FacetViewer({
|
||
// model: dataset
|
||
// });
|
||
my.FacetViewer = Backbone.View.extend({
|
||
className: 'recline-facet-viewer',
|
||
template: ' \
|
||
<div class="facets"> \
|
||
{{#facets}} \
|
||
<div class="facet-summary" data-facet="{{id}}"> \
|
||
<h3> \
|
||
{{id}} \
|
||
</h3> \
|
||
<ul class="facet-items"> \
|
||
{{#terms}} \
|
||
<li><a class="facet-choice js-facet-filter" data-value="{{term}}" href="#{{term}}">{{term}} ({{count}})</a></li> \
|
||
{{/terms}} \
|
||
{{#entries}} \
|
||
<li><a class="facet-choice js-facet-filter" data-value="{{time}}">{{term}} ({{count}})</a></li> \
|
||
{{/entries}} \
|
||
</ul> \
|
||
</div> \
|
||
{{/facets}} \
|
||
</div> \
|
||
',
|
||
|
||
events: {
|
||
'click .js-facet-filter': 'onFacetFilter'
|
||
},
|
||
initialize: function(model) {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.facets, 'all', this.render);
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var tmplData = {
|
||
fields: this.model.fields.toJSON()
|
||
};
|
||
tmplData.facets = _.map(this.model.facets.toJSON(), function(facet) {
|
||
if (facet._type === 'date_histogram') {
|
||
facet.entries = _.map(facet.entries, function(entry) {
|
||
entry.term = new Date(entry.time).toDateString();
|
||
return entry;
|
||
});
|
||
}
|
||
return facet;
|
||
});
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
// are there actually any facets to show?
|
||
if (this.model.facets.length > 0) {
|
||
this.$el.show();
|
||
} else {
|
||
this.$el.hide();
|
||
}
|
||
},
|
||
onHide: function(e) {
|
||
e.preventDefault();
|
||
this.$el.hide();
|
||
},
|
||
onFacetFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target= $(e.target);
|
||
var fieldId = $target.closest('.facet-summary').attr('data-facet');
|
||
var value = $target.attr('data-value');
|
||
this.model.queryState.addFilter({type: 'term', field: fieldId, term: value});
|
||
// have to trigger explicitly for some reason
|
||
this.model.query();
|
||
}
|
||
});
|
||
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
// Field Info
|
||
//
|
||
// For each field
|
||
//
|
||
// Id / Label / type / format
|
||
|
||
// Editor -- to change type (and possibly format)
|
||
// Editor for show/hide ...
|
||
|
||
// Summaries of fields
|
||
//
|
||
// Top values / number empty
|
||
// If number: max, min average ...
|
||
|
||
// Box to boot transform editor ...
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.Fields = Backbone.View.extend({
|
||
className: 'recline-fields-view',
|
||
template: ' \
|
||
<div class="panel-group fields-list well"> \
|
||
<h3>Fields <a href="#" class="js-show-hide">+</a></h3> \
|
||
{{#fields}} \
|
||
<div class="panel panel-default field"> \
|
||
<div class="panel-heading"> \
|
||
<i class="glyphicon glyphicon-file"></i> \
|
||
<h4> \
|
||
{{label}} \
|
||
<small> \
|
||
{{type}} \
|
||
<a class="accordion-toggle" data-toggle="collapse" href="#collapse{{id}}"> » </a> \
|
||
</small> \
|
||
</h4> \
|
||
</div> \
|
||
<div id="collapse{{id}}" class="panel-collapse collapse"> \
|
||
<div class="panel-body"> \
|
||
{{#facets}} \
|
||
<div class="facet-summary" data-facet="{{id}}"> \
|
||
<ul class="facet-items"> \
|
||
{{#terms}} \
|
||
<li class="facet-item"><span class="term">{{term}}</span> <span class="count">[{{count}}]</span></li> \
|
||
{{/terms}} \
|
||
</ul> \
|
||
</div> \
|
||
{{/facets}} \
|
||
<div class="clear"></div> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
{{/fields}} \
|
||
</div> \
|
||
',
|
||
|
||
initialize: function(model) {
|
||
var self = this;
|
||
_.bindAll(this, 'render');
|
||
|
||
// TODO: this is quite restrictive in terms of when it is re-run
|
||
// e.g. a change in type will not trigger a re-run atm.
|
||
// being more liberal (e.g. binding to all) can lead to being called a lot (e.g. for change:width)
|
||
this.listenTo(this.model.fields, 'reset', function(action) {
|
||
self.model.fields.each(function(field) {
|
||
field.facets.unbind('all', self.render);
|
||
field.facets.bind('all', self.render);
|
||
});
|
||
// fields can get reset or changed in which case we need to recalculate
|
||
self.model.getFieldsSummary();
|
||
self.render();
|
||
});
|
||
this.$el.find('.collapse').collapse();
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = {
|
||
fields: []
|
||
};
|
||
this.model.fields.each(function(field) {
|
||
var out = field.toJSON();
|
||
out.facets = field.facets.toJSON();
|
||
tmplData.fields.push(out);
|
||
});
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.FilterEditor = Backbone.View.extend({
|
||
className: 'recline-filter-editor well',
|
||
template: ' \
|
||
<div class="filters"> \
|
||
<h3>Filters</h3> \
|
||
<a href="#" class="js-add-filter">Add filter</a> \
|
||
<form class="form-stacked js-add" style="display: none;"> \
|
||
<div class="form-group"> \
|
||
<label>Field</label> \
|
||
<select class="fields form-control"> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
<div class="form-group"> \
|
||
<label>Filter type</label> \
|
||
<select class="filterType form-control"> \
|
||
<option value="term">Value</option> \
|
||
<option value="range">Range</option> \
|
||
<option value="geo_distance">Geo distance</option> \
|
||
</select> \
|
||
</div> \
|
||
<button type="submit" class="btn btn-default">Add</button> \
|
||
</form> \
|
||
<form class="form-stacked js-edit"> \
|
||
{{#filters}} \
|
||
{{{filterRender}}} \
|
||
{{/filters}} \
|
||
{{#filters.length}} \
|
||
<button type="submit" class="btn btn-default">Update</button> \
|
||
{{/filters.length}} \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
filterTemplates: {
|
||
term: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<input class="input-sm" type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
',
|
||
range: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<div class="form-group"> \
|
||
<label class="control-label" for="">From</label> \
|
||
<input class="input-sm" type="text" value="{{from}}" name="from" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</div> \
|
||
<div class="form-group"> \
|
||
<label class="control-label" for="">To</label> \
|
||
<input class="input-sm" type="text" value="{{to}}" name="to" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</div> \
|
||
</fieldset> \
|
||
</div> \
|
||
',
|
||
geo_distance: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<div class="form-group"> \
|
||
<label class="control-label" for="">Longitude</label> \
|
||
<input class="input-sm" type="text" value="{{point.lon}}" name="lon" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</div> \
|
||
<div class="form-group"> \
|
||
<label class="control-label" for="">Latitude</label> \
|
||
<input class="input-sm" type="text" value="{{point.lat}}" name="lat" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</div> \
|
||
<div class="form-group"> \
|
||
<label class="control-label" for="">Distance (km)</label> \
|
||
<input class="input-sm" type="text" value="{{distance}}" name="distance" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</div> \
|
||
</fieldset> \
|
||
</div> \
|
||
'
|
||
},
|
||
events: {
|
||
'click .js-remove-filter': 'onRemoveFilter',
|
||
'click .js-add-filter': 'onAddFilterShow',
|
||
'submit form.js-edit': 'onTermFiltersUpdate',
|
||
'submit form.js-add': 'onAddFilter'
|
||
},
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.listenTo(this.model.queryState, 'change change:filters:new-blank', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = $.extend(true, {}, this.model.queryState.toJSON());
|
||
// we will use idx in list as there id ...
|
||
tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
|
||
filter.id = idx;
|
||
return filter;
|
||
});
|
||
tmplData.fields = this.model.fields.toJSON();
|
||
tmplData.filterRender = function() {
|
||
return Mustache.render(self.filterTemplates[this.type], this);
|
||
};
|
||
var out = Mustache.render(this.template, tmplData);
|
||
this.$el.html(out);
|
||
},
|
||
onAddFilterShow: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
this.$el.find('form.js-add').show();
|
||
},
|
||
onAddFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
var filterType = $target.find('select.filterType').val();
|
||
var field = $target.find('select.fields').val();
|
||
this.model.queryState.addFilter({type: filterType, field: field});
|
||
},
|
||
onRemoveFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
var filterId = $target.attr('data-filter-id');
|
||
this.model.queryState.removeFilter(filterId);
|
||
},
|
||
onTermFiltersUpdate: function(e) {
|
||
var self = this;
|
||
e.preventDefault();
|
||
var filters = self.model.queryState.get('filters');
|
||
var $form = $(e.target);
|
||
_.each($form.find('input'), function(input) {
|
||
var $input = $(input);
|
||
var filterType = $input.attr('data-filter-type');
|
||
var fieldId = $input.attr('data-filter-field');
|
||
var filterIndex = parseInt($input.attr('data-filter-id'), 10);
|
||
var name = $input.attr('name');
|
||
var value = $input.val();
|
||
|
||
switch (filterType) {
|
||
case 'term':
|
||
filters[filterIndex].term = value;
|
||
break;
|
||
case 'range':
|
||
filters[filterIndex][name] = value;
|
||
break;
|
||
case 'geo_distance':
|
||
if(name === 'distance') {
|
||
filters[filterIndex].distance = parseFloat(value);
|
||
}
|
||
else {
|
||
filters[filterIndex].point[name] = parseFloat(value);
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
self.model.queryState.set({filters: filters, from: 0});
|
||
self.model.queryState.trigger('change');
|
||
}
|
||
});
|
||
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.Pager = Backbone.View.extend({
|
||
className: 'recline-pager',
|
||
template: ' \
|
||
<div class="pagination"> \
|
||
<ul class="pagination"> \
|
||
<li class="prev action-pagination-update"><a href="" class="btn btn-default">«</a></li> \
|
||
<li class="page-range"><a><label for="from">From</label><input id="from" name="from" type="text" value="{{from}}" /> – <label for="to">To</label><input id="to" name="to" type="text" value="{{to}}" /> </a></li> \
|
||
<li class="next action-pagination-update"><a href="" class="btn btn-default">»</a></li> \
|
||
</ul> \
|
||
</div> \
|
||
',
|
||
|
||
events: {
|
||
'click .action-pagination-update': 'onPaginationUpdate',
|
||
'change input': 'onFormSubmit'
|
||
},
|
||
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.queryState, 'change', this.render);
|
||
this.render();
|
||
},
|
||
onFormSubmit: function(e) {
|
||
e.preventDefault();
|
||
// filter is 0-based; form is 1-based
|
||
var formFrom = parseInt(this.$el.find('input[name="from"]').val())-1;
|
||
var formTo = parseInt(this.$el.find('input[name="to"]').val())-1;
|
||
var maxRecord = this.model.recordCount-1;
|
||
if (this.model.queryState.get('from') != formFrom) { // changed from; update from
|
||
this.model.queryState.set({from: Math.min(maxRecord, Math.max(formFrom, 0))});
|
||
} else if (this.model.queryState.get('to') != formTo) { // change to; update size
|
||
var to = Math.min(maxRecord, Math.max(formTo, 0));
|
||
this.model.queryState.set({size: Math.min(maxRecord+1, Math.max(to-formFrom+1, 1))});
|
||
}
|
||
},
|
||
onPaginationUpdate: function(e) {
|
||
e.preventDefault();
|
||
var $el = $(e.target);
|
||
var newFrom = 0;
|
||
var currFrom = this.model.queryState.get('from');
|
||
var size = this.model.queryState.get('size');
|
||
var updateQuery = false;
|
||
if ($el.parent().hasClass('prev')) {
|
||
newFrom = Math.max(currFrom - Math.max(0, size), 0);
|
||
updateQuery = newFrom != currFrom;
|
||
} else {
|
||
newFrom = Math.max(currFrom + size, 0);
|
||
updateQuery = (newFrom < this.model.recordCount);
|
||
}
|
||
if (updateQuery) {
|
||
this.model.queryState.set({from: newFrom});
|
||
}
|
||
},
|
||
render: function() {
|
||
var tmplData = this.model.toJSON();
|
||
var from = parseInt(this.model.queryState.get('from'));
|
||
tmplData.from = from+1;
|
||
tmplData.to = Math.min(from+this.model.queryState.get('size'), this.model.recordCount);
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
return this;
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.QueryEditor = Backbone.View.extend({
|
||
className: 'recline-query-editor',
|
||
template: ' \
|
||
<form action="" method="GET" class="form-inline" role="form"> \
|
||
<div class="form-group"> \
|
||
<div class="input-group text-query"> \
|
||
<div class="input-group-addon"> \
|
||
<i class="glyphicon glyphicon-search"></i> \
|
||
</div> \
|
||
<label for="q">Search</label> \
|
||
<input class="form-control search-query" type="text" id="q" name="q" value="{{q}}" placeholder="Search data ..."> \
|
||
</div> \
|
||
</div> \
|
||
<button type="submit" class="btn btn-default">Go »</button> \
|
||
</form> \
|
||
',
|
||
|
||
events: {
|
||
'submit form': 'onFormSubmit'
|
||
},
|
||
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model, 'change', this.render);
|
||
this.render();
|
||
},
|
||
onFormSubmit: function(e) {
|
||
e.preventDefault();
|
||
var query = this.$el.find('.search-query').val();
|
||
this.model.set({q: query});
|
||
},
|
||
render: function() {
|
||
var tmplData = this.model.toJSON();
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.ValueFilter = Backbone.View.extend({
|
||
className: 'recline-filter-editor well',
|
||
template: ' \
|
||
<div class="filters"> \
|
||
<h3>Filters</h3> \
|
||
<button class="btn js-add-filter add-filter">Add filter</button> \
|
||
<form class="form-stacked js-add" style="display: none;"> \
|
||
<fieldset> \
|
||
<label>Field</label> \
|
||
<select class="fields form-control"> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
<button type="submit" class="btn">Add</button> \
|
||
</fieldset> \
|
||
</form> \
|
||
<form class="form-stacked js-edit"> \
|
||
{{#filters}} \
|
||
{{{filterRender}}} \
|
||
{{/filters}} \
|
||
{{#filters.length}} \
|
||
<button type="submit" class="btn update-filter">Update</button> \
|
||
{{/filters.length}} \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
filterTemplates: {
|
||
term: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
{{field}} \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
<input type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
'
|
||
},
|
||
events: {
|
||
'click .js-remove-filter': 'onRemoveFilter',
|
||
'click .js-add-filter': 'onAddFilterShow',
|
||
'submit form.js-edit': 'onTermFiltersUpdate',
|
||
'submit form.js-add': 'onAddFilter'
|
||
},
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.listenTo(this.model.queryState, 'change change:filters:new-blank', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = $.extend(true, {}, this.model.queryState.toJSON());
|
||
// we will use idx in list as the id ...
|
||
tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
|
||
filter.id = idx;
|
||
return filter;
|
||
});
|
||
tmplData.fields = this.model.fields.toJSON();
|
||
tmplData.filterRender = function() {
|
||
return Mustache.render(self.filterTemplates.term, this);
|
||
};
|
||
var out = Mustache.render(this.template, tmplData);
|
||
this.$el.html(out);
|
||
},
|
||
updateFilter: function(input) {
|
||
var self = this;
|
||
var filters = self.model.queryState.get('filters');
|
||
var $input = $(input);
|
||
var filterIndex = parseInt($input.attr('data-filter-id'), 10);
|
||
var value = $input.val();
|
||
filters[filterIndex].term = value;
|
||
},
|
||
onAddFilterShow: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
this.$el.find('form.js-add').show();
|
||
},
|
||
onAddFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
var field = $target.find('select.fields').val();
|
||
this.model.queryState.addFilter({type: 'term', field: field});
|
||
},
|
||
onRemoveFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
var filterId = $target.attr('data-filter-id');
|
||
this.model.queryState.removeFilter(filterId);
|
||
},
|
||
onTermFiltersUpdate: function(e) {
|
||
var self = this;
|
||
e.preventDefault();
|
||
var filters = self.model.queryState.get('filters');
|
||
var $form = $(e.target);
|
||
_.each($form.find('input'), function(input) {
|
||
self.updateFilter(input);
|
||
});
|
||
self.model.queryState.set({filters: filters, from: 0});
|
||
self.model.queryState.trigger('change');
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|