[build,make][s]: build latest files plus a new recline.dataset.js file.

This commit is contained in:
Rufus Pollock 2012-07-03 18:13:51 +01:00
parent 731e6093dd
commit 8bc5154dda
4 changed files with 922 additions and 212 deletions

127
dist/recline.css vendored
View File

@ -204,94 +204,6 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit {
color: #999;
}
/**********************************************************
* Transform Dialog
*********************************************************/
textarea.expression-preview-code {
font-family: monospace;
height: 5em;
vertical-align: top;
}
.expression-preview-parsing-status {
color: #999;
}
.expression-preview-parsing-status.error {
color: red;
}
#expression-preview-tabs-preview,
#expression-preview-tabs-help,
#expression-preview-tabs-history,
#expression-preview-tabs-starred {
padding: 5px;
overflow: hidden;
}
#expression-preview-tabs-preview > div,
#expression-preview-tabs-help > div,
#expression-preview-tabs-history > div,
#expression-preview-tabs-starred {
height: 200px;
overflow: auto;
}
#expression-preview-tabs-preview td, #expression-preview-tabs-preview th,
#expression-preview-tabs-help td, #expression-preview-tabs-help th,
#expression-preview-tabs-history td, #expression-preview-tabs-history th,
#expression-preview-tabs-starred td, #expression-preview-tabs-starred th {
padding: 5px;
}
.expression-preview-table-wrapper {
padding: 7px;
}
.expression-preview-container td {
padding: 2px 5px;
border-top: 1px solid #ccc;
}
td.expression-preview-heading {
border-top: none;
background: #ddd;
font-weight: bold;
}
td.expression-preview-value {
max-width: 250px !important;
overflow-x: hidden;
}
.expression-preview-special-value {
color: #aaa;
}
.expression-preview-help-container h3 {
margin-top: 15px;
margin-bottom: 7px;
border-bottom: 1px solid #999;
}
.expression-preview-doc-item-title {
font-weight: bold;
text-align: right;
}
.expression-preview-doc-item-params {
}
.expression-preview-doc-item-returns {
}
.expression-preview-doc-item-desc {
color: #666;
}
/**********************************************************
* Read-only mode
*********************************************************/
@ -691,3 +603,42 @@ classes should alter those!
cursor: pointer;
}
.recline-transform .script {
margin-right: 10px;
}
.recline-transform .script textarea {
width: 100%;
height: 100px;
font-family: monospace;
}
.recline-transform h2 {
margin-bottom: 10px;
}
.recline-transform h2 .okButton {
margin-left: 10px;
margin-top: -2px;
}
.recline-transform .preview {
margin-right: 10px;
}
.expression-preview-parsing-status {
color: #999;
}
.expression-preview-parsing-status.error {
color: red;
}
.recline-transform .before-after .after {
font-style: italic;
}
.recline-transform .before-after .after.different {
font-weight: bold;
}

748
dist/recline.dataset.js vendored Normal file
View File

@ -0,0 +1,748 @@
// # Recline Backbone Models
this.recline = this.recline || {};
this.recline.Model = this.recline.Model || {};
(function($, my) {
// ## <a id="dataset">Dataset</a>
my.Dataset = Backbone.Model.extend({
__type__: 'Dataset',
// ### initialize
initialize: function() {
_.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.currentRecords = 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', this.query);
this.queryState.bind('facet:add', this.query);
// 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 (this.backend == recline.Backend.Memory) {
this.fetch();
}
},
// ### fetch
//
// Retrieve dataset and (some) records from the backend.
fetch: function() {
var self = this;
var dfd = $.Deferred();
if (this.backend !== recline.Backend.Memory) {
this.backend.fetch(this.toJSON())
.done(handleResults)
.fail(function(arguments) {
dfd.reject(arguments);
});
} else {
// special case where we have been given data directly
handleResults({
records: this.get('records'),
fields: this.get('fields'),
useMemoryStore: true
});
}
function handleResults(results) {
var out = self._normalizeRecordsAndFields(results.records, results.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(arguments) {
dfd.reject(arguments);
});
}
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 && typeof fields[0] === 'string') {
// Rename duplicate fieldIds as each field name needs to be
// unique.
var seen = {};
fields = _.map(fields, function(field, index) {
// 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());
},
transform: function(editFunc) {
var self = this;
if (!this._store.transform) {
alert('Transform is not supported with this backend: ' + this.get('backend'));
return;
}
this.trigger('recline:flash', {message: "Updating all visible docs. This could take a while...", persist: true, loader: true});
this._store.transform(editFunc).done(function() {
// reload data as records have changed
self.query();
self.trigger('recline:flash', {message: "Records updated successfully"});
});
},
// ### 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.currentRecords and are
// also returned.
query: function(queryObj) {
var self = this;
var dfd = $.Deferred();
this.trigger('query:start');
if (queryObj) {
this.queryState.set(queryObj, {silent: true});
}
var actualQuery = this.queryState.toJSON();
this._store.query(actualQuery, this.toJSON())
.done(function(queryResult) {
self._handleQueryResult(queryResult);
self.trigger('query:done');
dfd.resolve(self.currentRecords);
})
.fail(function(arguments) {
self.trigger('query:fail', arguments);
dfd.reject(arguments);
});
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.bind('change', function(doc) {
self._changes.updates.push(doc.toJSON());
});
_doc.bind('destroy', function(doc) {
self._changes.deletes.push(doc.toJSON());
});
return _doc;
});
self.currentRecords.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 = $.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();
},
// ### recordSummary
//
// Get a simple html summary of a Dataset record in form of key/value list
recordSummary: function(record) {
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>: ' + record.getFieldValue(field) + '</div>';
}
});
html += '</div>';
return html;
},
// ### _backendFromString(backendString)
//
// See backend argument to initialize for details
_backendFromString: function(backendString) {
var parts = backendString.split('.');
// walk through the specified path xxx.yyy.zzz to get the final object which should be backend class
var current = window;
for(ii=0;ii<parts.length;ii++) {
if (!current) {
break;
}
current = current[parts[ii]];
}
if (current) {
return current;
}
// alternatively we just had a simple string
var backend = null;
if (recline && recline.Backend) {
_.each(_.keys(recline.Backend), function(name) {
if (name.toLowerCase() === backendString.toLowerCase()) {
backend = recline.Backend[name];
}
});
}
return backend;
}
});
// ### Dataset.restore
//
// Restore a Dataset instance from a serialized state. Serialized state for a
// Dataset is an Object like:
//
// <pre>
// {
// backend: {backend type - i.e. value of dataset.backend.__type__}
// dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler }
// // convenience - if url provided and dataste not this be used as dataset url
// url: {dataset url}
// ...
// }
my.Dataset.restore = function(state) {
var dataset = null;
// hack-y - restoring a memory dataset does not mean much ...
if (state.backend === 'memory') {
var datasetInfo = {
records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}]
};
} else {
var datasetInfo = {
url: state.url,
backend: state.backend
};
}
dataset = new recline.Model.Dataset(datasetInfo);
return dataset;
};
// ## <a id="record">A Record (aka Row)</a>
//
// A single entry or row in the dataset
my.Record = Backbone.Model.extend({
__type__: 'Record',
initialize: function() {
_.bindAll(this, 'getFieldValue');
},
// ### getFieldValue
//
// For the provided Field get the corresponding rendered computed data value
// for this record.
getFieldValue: function(field) {
val = this.getFieldValueUnrendered(field);
if (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.
getFieldValueUnrendered: function(field) {
var val = this.get(field.id);
if (field.deriver) {
val = field.deriver(val, field, this);
}
return val;
},
// 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({
__type__: 'RecordList',
model: my.Record
});
// ## <a id="field">A Field (aka Column) on a Dataset</a>
my.Field = Backbone.Model.extend({
// ### defaults - define default values
defaults: {
label: null,
type: 'string',
format: null,
is_derived: false
},
// ### initialize
//
// @param {Object} data: standard Backbone model attributes
//
// @param {Object} options: renderer and/or deriver functions.
initialize: function(data, options) {
// if a hash not passed in the first argument throw error
if ('0' in data) {
throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
}
if (this.attributes.label === null) {
this.set({label: this.id});
}
if (options) {
this.renderer = options.renderer;
this.deriver = options.deriver;
}
if (!this.renderer) {
this.renderer = this.defaultRenderers[this.get('type')];
}
this.facets = new my.FacetList();
},
defaultRenderers: {
object: function(val, field, doc) {
return JSON.stringify(val);
},
geo_point: function(val, field, doc) {
return JSON.stringify(val);
},
'float': function(val, field, doc) {
var format = field.get('format');
if (format === 'percentage') {
return val + '%';
}
return val;
},
'string': function(val, field, doc) {
var format = field.get('format');
if (format === 'markdown') {
if (typeof Showdown !== 'undefined') {
var showdown = new Showdown.converter();
out = showdown.makeHtml(val);
return out;
} else {
return val;
}
} else if (format == 'plain') {
return val;
} else {
// as this is the default and default type is string may get things
// here that are not actually strings
if (val && typeof val === 'string') {
val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
}
return val
}
}
}
});
my.FieldList = Backbone.Collection.extend({
model: my.Field
});
// ## <a id="query">Query</a>
my.Query = Backbone.Model.extend({
defaults: function() {
return {
size: 100,
from: 0,
q: '',
facets: {},
filters: []
};
},
_filterTemplates: {
term: {
type: 'term',
field: '',
term: ''
},
geo_distance: {
distance: 10,
unit: 'km',
point: {
lon: 0,
lat: 0
}
}
},
// ### addFilter
//
// Add a new filter (appended 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 full specified so use template and over-write
if (_.keys(filter).length <= 2) {
ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter);
}
var filters = this.get('filters');
filters.push(ourfilter);
this.trigger('change:filters:new-blank');
},
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) {
var facets = this.get('facets');
// Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)
if (_.contains(_.keys(facets), fieldId)) {
return;
}
facets[fieldId] = {
terms: { field: fieldId }
};
this.set({facets: facets}, {silent: true});
this.trigger('facet:add', this);
},
addHistogramFacet: function(fieldId) {
var facets = this.get('facets');
facets[fieldId] = {
date_histogram: {
field: fieldId,
interval: 'day'
}
};
this.set({facets: facets}, {silent: true});
this.trigger('facet:add', this);
}
});
// ## <a id="facet">A Facet (Result)</a>
my.Facet = Backbone.Model.extend({
defaults: function() {
return {
_type: 'terms',
total: 0,
other: 0,
missing: 0,
terms: []
};
}
});
// ## A Collection/List of Facets
my.FacetList = Backbone.Collection.extend({
model: my.Facet
});
// ## Object State
//
// Convenience Backbone model for storing (configuration) state of objects like Views.
my.ObjectState = Backbone.Model.extend({
});
// ## Backbone.sync
//
// Override Backbone.sync to hand off to sync function in relevant backend
Backbone.sync = function(method, model, options) {
return model.backend.sync(method, model, options);
};
}(jQuery, this.recline.Model));
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
this.recline.Backend.Memory = this.recline.Backend.Memory || {};
(function($, my) {
my.__type__ = 'memory';
// ## 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 data 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(data, fields) {
var self = this;
this.data = data;
if (fields) {
this.fields = fields;
} else {
if (data) {
this.fields = _.map(data[0], function(value, key) {
return {id: key};
});
}
}
this.update = function(doc) {
_.each(self.data, function(internalDoc, idx) {
if(doc.id === internalDoc.id) {
self.data[idx] = doc;
}
});
};
this.delete = function(doc) {
var newdocs = _.reject(self.data, function(internalDoc) {
return (doc.id === internalDoc.id);
});
this.data = newdocs;
};
this.save = function(changes, dataset) {
var self = this;
var dfd = $.Deferred();
// TODO _.each(changes.creates) { ... }
_.each(changes.updates, function(record) {
self.update(record);
});
_.each(changes.deletes, function(record) {
self.delete(record);
});
dfd.resolve();
return dfd.promise();
},
this.query = function(queryObj) {
var dfd = $.Deferred();
var numRows = queryObj.size || this.data.length;
var start = queryObj.from || 0;
var results = this.data;
results = this._applyFilters(results, queryObj);
results = this._applyFreeTextQuery(results, queryObj);
// not complete sorting!
_.each(queryObj.sort, function(sortObj) {
var fieldName = _.keys(sortObj)[0];
results = _.sortBy(results, function(doc) {
var _out = doc[fieldName];
return _out;
});
if (sortObj[fieldName].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) {
_.each(queryObj.filters, function(filter) {
// if a term filter ...
if (filter.type === 'term') {
results = _.filter(results, function(doc) {
return (doc[filter.field] == filter.term);
});
}
});
return results;
};
// we OR across fields but AND across terms in query string
this._applyFreeTextQuery = function(results, queryObj) {
if (queryObj.q) {
var terms = queryObj.q.split(' ');
results = _.filter(results, function(rawdoc) {
var matches = true;
_.each(terms, function(term) {
var foundmatch = false;
_.each(self.fields, function(field) {
var value = rawdoc[field.id];
if (value !== null) {
value = value.toString();
} else {
// value can be null (apparently in some cases)
value = '';
}
// TODO regexes?
foundmatch = foundmatch || (value.toLowerCase() === term.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.transform = function(editFunc) {
var toUpdate = costco.mapDocs(this.data, editFunc);
// TODO: very inefficient -- could probably just walk the documents and updates in tandem and update
_.each(toUpdate.updates, function(record, idx) {
self.data[idx] = record;
});
return this.save(toUpdate);
};
};
}(jQuery, this.recline.Backend.Memory));

255
dist/recline.js vendored
View File

@ -188,6 +188,9 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
my.__type__ = 'dataproxy';
// URL for the dataproxy
my.dataproxy_url = 'http://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;
// ## load
//
@ -230,12 +233,11 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
// a crude way to catch those errors.
var _wrapInTimeout = function(ourFunction) {
var dfd = $.Deferred();
var timeout = 5000;
var timer = setTimeout(function() {
dfd.reject({
message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds'
});
}, timeout);
}, my.timeout);
ourFunction.done(function(arguments) {
clearTimeout(timer);
dfd.resolve(arguments);
@ -743,7 +745,12 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {};
var foundmatch = false;
_.each(self.fields, function(field) {
var value = rawdoc[field.id];
if (value !== null) { value = value.toString(); }
if (value !== null) {
value = value.toString();
} else {
// value can be null (apparently in some cases)
value = '';
}
// TODO regexes?
foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase());
// TODO: early out (once we are true should break to spare unnecessary testing)
@ -795,6 +802,15 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {};
});
return facetResults;
};
this.transform = function(editFunc) {
var toUpdate = costco.mapDocs(this.data, editFunc);
// TODO: very inefficient -- could probably just walk the documents and updates in tandem and update
_.each(toUpdate.updates, function(record, idx) {
self.data[idx] = record;
});
return this.save(toUpdate);
};
};
}(jQuery, this.recline.Backend.Memory));
@ -820,9 +836,9 @@ var costco = function() {
;
if (!after) after = {};
if (currentColumn) {
preview.push({before: JSON.stringify(before[currentColumn]), after: JSON.stringify(after[currentColumn])});
preview.push({before: before[currentColumn], after: after[currentColumn]});
} else {
preview.push({before: JSON.stringify(before), after: JSON.stringify(after)});
preview.push({before: before, after: after});
}
}
return preview;
@ -853,9 +869,9 @@ var costco = function() {
});
return {
edited: edited,
updates: edited,
docs: updatedDocs,
deleted: deleted,
deletes: deleted,
failed: failed
};
}
@ -895,7 +911,7 @@ my.Dataset = Backbone.Model.extend({
creates: []
};
this.facets = new my.FacetList();
this.docCount = null;
this.recordCount = null;
this.queryState = new my.Query();
this.queryState.bind('change', this.query);
this.queryState.bind('facet:add', this.query);
@ -1017,6 +1033,20 @@ my.Dataset = Backbone.Model.extend({
return this._store.save(this._changes, this.toJSON());
},
transform: function(editFunc) {
var self = this;
if (!this._store.transform) {
alert('Transform is not supported with this backend: ' + this.get('backend'));
return;
}
this.trigger('recline:flash', {message: "Updating all visible docs. This could take a while...", persist: true, loader: true});
this._store.transform(editFunc).done(function() {
// reload data as records have changed
self.query();
self.trigger('recline:flash', {message: "Records updated successfully"});
});
},
// ### query
//
// AJAX method with promise API to get records from the backend.
@ -1032,7 +1062,7 @@ my.Dataset = Backbone.Model.extend({
this.trigger('query:start');
if (queryObj) {
this.queryState.set(queryObj);
this.queryState.set(queryObj, {silent: true});
}
var actualQuery = this.queryState.toJSON();
@ -1051,7 +1081,7 @@ my.Dataset = Backbone.Model.extend({
_handleQueryResult: function(queryResult) {
var self = this;
self.docCount = queryResult.total;
self.recordCount = queryResult.total;
var docs = _.map(queryResult.hits, function(hit) {
var _doc = new my.Record(hit);
_doc.bind('change', function(doc) {
@ -1074,11 +1104,13 @@ my.Dataset = Backbone.Model.extend({
toTemplateJSON: function() {
var data = this.toJSON();
data.docCount = this.docCount;
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.
@ -1104,6 +1136,20 @@ my.Dataset = Backbone.Model.extend({
return dfd.promise();
},
// ### recordSummary
//
// Get a simple html summary of a Dataset record in form of key/value list
recordSummary: function(record) {
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>: ' + record.getFieldValue(field) + '</div>';
}
});
html += '</div>';
return html;
},
// ### _backendFromString(backendString)
//
// See backend argument to initialize for details
@ -1198,16 +1244,6 @@ my.Record = Backbone.Model.extend({
return val;
},
summary: function(fields) {
var html = '';
for (key in this.attributes) {
if (key != 'id') {
html += '<div><strong>' + key + '</strong>: '+ this.attributes[key] + '</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)
@ -1258,6 +1294,9 @@ my.Field = Backbone.Model.extend({
object: function(val, field, doc) {
return JSON.stringify(val);
},
geo_point: function(val, field, doc) {
return JSON.stringify(val);
},
'float': function(val, field, doc) {
var format = field.get('format');
if (format === 'percentage') {
@ -2254,7 +2293,7 @@ my.Map = Backbone.View.extend({
// 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'],
geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location', 'geo', 'lonlat'],
initialize: function(options) {
var self = this;
@ -2856,7 +2895,7 @@ my.MultiView = Backbone.View.extend({
</div> \
</div> \
<div class="recline-results-info"> \
Results found <span class="doc-count">{{docCount}}</span> \
<span class="doc-count">{{recordCount}}</span> records\
</div> \
<div class="menu-right"> \
<div class="btn-group" data-toggle="buttons-checkbox"> \
@ -2887,7 +2926,7 @@ my.MultiView = Backbone.View.extend({
this.pageViews = [{
id: 'grid',
label: 'Grid',
view: new my.Grid({
view: new my.SlickGrid({
model: this.model,
state: this.state.get('view-grid')
}),
@ -2912,6 +2951,12 @@ my.MultiView = Backbone.View.extend({
model: this.model,
state: this.state.get('view-timeline')
}),
}, {
id: 'transform',
label: 'Transform',
view: new my.Transform({
model: this.model
})
}];
}
// these must be called after pageViews are created
@ -2933,7 +2978,7 @@ my.MultiView = Backbone.View.extend({
});
this.model.bind('query:done', function() {
self.clearNotifications();
self.el.find('.doc-count').text(self.model.docCount || 'Unknown');
self.el.find('.doc-count').text(self.model.recordCount || 'Unknown');
});
this.model.bind('query:fail', function(error) {
self.clearNotifications();
@ -3037,6 +3082,8 @@ my.MultiView = Backbone.View.extend({
this.$filterEditor.toggle();
} else if (action === 'fields') {
this.$fieldsView.toggle();
} else if (action === 'transform') {
this.transformView.el.toggle();
}
},
@ -3256,6 +3303,8 @@ this.recline.View = this.recline.View || {};
// https://github.com/mleibman/SlickGrid
//
// Initialize it with a `recline.Model.Dataset`.
//
// NB: you need an explicit height on the element for slickgrid to work
my.SlickGrid = Backbone.View.extend({
tagName: "div",
className: "recline-slickgrid",
@ -3263,6 +3312,7 @@ my.SlickGrid = Backbone.View.extend({
initialize: function(modelEtc) {
var self = this;
this.el = $(this.el);
this.el.addClass('recline-slickgrid');
_.bindAll(this, 'render');
this.model.currentRecords.bind('add', this.render);
this.model.currentRecords.bind('reset', this.render);
@ -3301,7 +3351,6 @@ my.SlickGrid = Backbone.View.extend({
render: function() {
var self = this;
this.el = $(this.el);
var options = {
enableCellNavigation: true,
@ -3648,7 +3697,7 @@ my.Timeline = Backbone.View.extend({
"startDate": start,
"endDate": end,
"headline": String(record.get('title') || ''),
"text": record.get('description') || record.summary()
"text": record.get('description') || this.model.recordSummary(record)
};
return tlEntry;
} else {
@ -3733,61 +3782,23 @@ this.recline.View = this.recline.View || {};
// ## ColumnTransform
//
// View (Dialog) for doing data transformations (on columns of data).
my.ColumnTransform = Backbone.View.extend({
className: 'transform-column-view modal fade in',
// View (Dialog) for doing data transformations
my.Transform = Backbone.View.extend({
className: 'recline-transform',
template: ' \
<div class="modal-header"> \
<a class="close" data-dismiss="modal">×</a> \
<h3>Functional transform on column {{name}}</h3> \
<div class="script"> \
<h2> \
Transform Script \
<button class="okButton btn btn-primary">Run on all records</button> \
</h2> \
<textarea class="expression-preview-code"></textarea> \
</div> \
<div class="modal-body"> \
<div class="grid-layout layout-tight layout-full"> \
<table> \
<tbody> \
<tr> \
<td colspan="4"> \
<div class="grid-layout layout-tight layout-full"> \
<table rows="4" cols="4"> \
<tbody> \
<tr style="vertical-align: bottom;"> \
<td colspan="4"> \
Expression \
</td> \
</tr> \
<tr> \
<td colspan="3"> \
<div class="input-container"> \
<textarea class="expression-preview-code"></textarea> \
</div> \
</td> \
<td class="expression-preview-parsing-status" width="150" style="vertical-align: top;"> \
No syntax error. \
</td> \
</tr> \
<tr> \
<td colspan="4"> \
<div id="expression-preview-tabs"> \
<span>Preview</span> \
<div id="expression-preview-tabs-preview"> \
<div class="expression-preview-container"> \
</div> \
</div> \
</div> \
</td> \
</tr> \
</tbody> \
</table> \
</div> \
</td> \
</tr> \
</tbody> \
</table> \
</div> \
<div class="expression-preview-parsing-status"> \
No syntax error. \
</div> \
<div class="modal-footer"> \
<button class="okButton btn primary">&nbsp;&nbsp;Update All&nbsp;&nbsp;</button> \
<button class="cancelButton btn danger">Cancel</button> \
<div class="preview"> \
<h3>Preview</h3> \
<div class="expression-preview-container"></div> \
</div> \
',
@ -3796,19 +3807,23 @@ my.ColumnTransform = Backbone.View.extend({
'keydown .expression-preview-code': 'onEditorKeydown'
},
initialize: function() {
initialize: function(options) {
this.el = $(this.el);
this.render();
},
render: function() {
var htmls = Mustache.render(this.template,
{name: this.state.currentColumn}
);
var htmls = Mustache.render(this.template);
this.el.html(htmls);
// Put in the basic (identity) transform script
// TODO: put this into the template?
var editor = this.el.find('.expression-preview-code');
editor.val("function(doc) {\n doc['"+ this.state.currentColumn+"'] = doc['"+ this.state.currentColumn+"'];\n return doc;\n}");
if (this.model.fields.length > 0) {
var col = this.model.fields.models[0].id;
} else {
var col = 'unknown';
}
editor.val("function(doc) {\n doc['"+ col +"'] = doc['"+ col +"'];\n return doc;\n}");
editor.focus().get(0).setSelectionRange(18, 18);
editor.keydown();
},
@ -3821,58 +3836,34 @@ my.ColumnTransform = Backbone.View.extend({
this.trigger('recline:flash', {message: "Error with function! " + editFunc.errorMessage});
return;
}
this.el.modal('hide');
this.trigger('recline:flash', {message: "Updating all visible docs. This could take a while...", persist: true, loader: true});
var docs = self.model.currentRecords.map(function(doc) {
return doc.toJSON();
});
// TODO: notify about failed docs?
var toUpdate = costco.mapDocs(docs, editFunc).edited;
var totalToUpdate = toUpdate.length;
function onCompletedUpdate() {
totalToUpdate += -1;
if (totalToUpdate === 0) {
self.trigger('recline:flash', {message: toUpdate.length + " records updated successfully"});
alert('WARNING: We have only updated the docs in this view. (Updating of all docs not yet implemented!)');
self.remove();
}
}
// TODO: Very inefficient as we search through all docs every time!
_.each(toUpdate, function(editedDoc) {
var realDoc = self.model.currentRecords.get(editedDoc.id);
realDoc.set(editedDoc);
realDoc.save().then(onCompletedUpdate).fail(onCompletedUpdate);
});
this.el.remove();
this.model.transform(editFunc);
},
editPreviewTemplate: ' \
<div class="expression-preview-table-wrapper"> \
<table class="table table-condensed"> \
<table class="table table-condensed table-bordered before-after"> \
<thead> \
<tr> \
<th class="expression-preview-heading"> \
before \
</th> \
<th class="expression-preview-heading"> \
after \
</th> \
<th>Field</th> \
<th>Before</th> \
<th>After</th> \
</tr> \
</thead> \
<tbody> \
{{#rows}} \
{{#row}} \
<tr> \
<td class="expression-preview-value"> \
<td> \
{{field}} \
</td> \
<td class="before {{#different}}different{{/different}}"> \
{{before}} \
</td> \
<td class="expression-preview-value"> \
<td class="after {{#different}}different{{/different}}"> \
{{after}} \
</td> \
</tr> \
{{/rows}} \
{{/row}} \
</tbody> \
</table> \
</div> \
',
onEditorKeydown: function(e) {
@ -3886,10 +3877,26 @@ my.ColumnTransform = Backbone.View.extend({
var docs = self.model.currentRecords.map(function(doc) {
return doc.toJSON();
});
var previewData = costco.previewTransform(docs, editFunc, self.state.currentColumn);
var previewData = costco.previewTransform(docs, editFunc);
var $el = self.el.find('.expression-preview-container');
var templated = Mustache.render(self.editPreviewTemplate, {rows: previewData.slice(0,4)});
$el.html(templated);
var fields = self.model.fields.toJSON();
var rows = _.map(previewData.slice(0,4), function(row) {
return _.map(fields, function(field) {
return {
field: field.id,
before: row.before[field.id],
after: row.after[field.id],
different: !_.isEqual(row.before[field.id], row.after[field.id])
}
});
});
$el.html('');
_.each(rows, function(row) {
var templated = Mustache.render(self.editPreviewTemplate, {
row: row
});
$el.append(templated);
});
} else {
errors.text(editFunc.errorMessage);
}

4
make
View File

@ -7,6 +7,10 @@ def cat():
print("** Combining js files")
cmd = 'ls src/*.js | grep -v couchdb | xargs cat > dist/recline.js'
os.system(cmd)
cmd = 'cat src/model.js src/backend.memory.js > dist/recline.dataset.js'
os.system(cmd)
print("** Combining css files")
cmd = 'cat css/*.css > dist/recline.css'
os.system(cmd)