// # Recline Backbone Models this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; (function($, my) { // ## Dataset my.Dataset = Backbone.Model.extend({ constructor: function Dataset() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### 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.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', 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.records 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.records); }) .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.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 = $.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) // // See backend argument to initialize for details _backendFromString: function(backendString) { var parts = backendString.split('.'); // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class var current = window; for(ii=0;ii // { // backend: {backend type - i.e. value of dataset.backend.__type__} // dataset: {dataset info needed for loading -- result of dataset.toJSON() would be sufficient but can be simpler } // // convenience - if url provided and dataste not this be used as dataset url // url: {dataset url} // ... // } my.Dataset.restore = function(state) { var dataset = null; // hack-y - restoring a memory dataset does not mean much ... if (state.backend === 'memory') { 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 Record // // A single record (or row) in the dataset my.Record = Backbone.Model.extend({ constructor: function Record() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### initialize // // Create a Record // // You usually will not do this directly but will have records created by // Dataset e.g. in query method // // Certain methods require presence of a fields attribute (identical to that on Dataset) initialize: function() { _.bindAll(this, 'getFieldValue'); }, // ### getFieldValue // // For the provided Field get the corresponding rendered computed data value // for this record. 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; }, // ### summary // // Get a simple html summary of this record in form of key/value list summary: function(record) { var self = this; var html = '
'; this.fields.each(function(field) { if (field.id != 'id') { html += '
' + field.get('label') + ': ' + self.getFieldValue(field) + '
'; } }); html += '
'; return html; }, // Override Backbone save, fetch and destroy so they do nothing // Instead, Dataset object that created this Record should take care of // handling these changes (discovery will occur via event notifications) // WARNING: these will not persist *unless* you call save on Dataset fetch: function() {}, save: function() {}, destroy: function() { this.trigger('destroy', this); } }); // ## A Backbone collection of Records my.RecordList = Backbone.Collection.extend({ constructor: function RecordList() { Backbone.Collection.prototype.constructor.apply(this, arguments); }, model: my.Record }); // ## A Field (aka Column) on a Dataset my.Field = Backbone.Model.extend({ constructor: function Field() { Backbone.Model.prototype.constructor.apply(this, arguments); }, // ### defaults - define default values defaults: { label: null, type: 'string', format: null, is_derived: false }, // ### initialize // // @param {Object} data: standard Backbone model attributes // // @param {Object} options: renderer and/or deriver functions. initialize: function(data, options) { // if a hash not passed in the first argument throw error if ('0' in data) { throw new Error('Looks like you did not pass a proper hash with id to Field constructor'); } if (this.attributes.label === null) { this.set({label: this.id}); } if (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, '$1'); } return val } } } }); my.FieldList = Backbone.Collection.extend({ constructor: function FieldList() { Backbone.Collection.prototype.constructor.apply(this, arguments); }, model: my.Field }); // ## Query my.Query = Backbone.Model.extend({ constructor: function Query() { Backbone.Model.prototype.constructor.apply(this, arguments); }, defaults: function() { return { size: 100, from: 0, q: '', facets: {}, filters: [] }; }, _filterTemplates: { term: { type: 'term', 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 addFacet: function(fieldId) { var facets = this.get('facets'); // Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field) if (_.contains(_.keys(facets), fieldId)) { return; } facets[fieldId] = { terms: { field: fieldId } }; this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); }, addHistogramFacet: function(fieldId) { var facets = this.get('facets'); facets[fieldId] = { date_histogram: { field: fieldId, interval: 'day' } }; this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); } }); // ## A Facet (Result) 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); }; }(jQuery, this.recline.Model));