PageRenderTime 76ms CodeModel.GetById 16ms RepoModel.GetById 1ms app.codeStats 0ms

/ckan/public/scripts/vendor/recline/recline.js

https://bitbucket.org/philippkueng/ckan-liip
JavaScript | 4617 lines | 4146 code | 182 blank | 289 comment | 183 complexity | 66532048416b4a9eeffdf56331eef71e MD5 | raw file
  1. this.recline = this.recline || {};
  2. this.recline.Backend = this.recline.Backend || {};
  3. this.recline.Backend.Ckan = this.recline.Backend.Ckan || {};
  4. (function($, my) {
  5. // ## CKAN Backend
  6. //
  7. // This provides connection to the CKAN DataStore (v2)
  8. //
  9. // General notes
  10. //
  11. // * Every dataset must have an id equal to its resource id on the CKAN instance
  12. // * You should set the CKAN API endpoint for requests by setting API_ENDPOINT value on this module (recline.Backend.Ckan.API_ENDPOINT)
  13. my.__type__ = 'ckan';
  14. // Default CKAN API endpoint used for requests (you can change this but it will affect every request!)
  15. my.API_ENDPOINT = 'http://datahub.io/api';
  16. // ### fetch
  17. my.fetch = function(dataset) {
  18. var wrapper = my.DataStore();
  19. var dfd = $.Deferred();
  20. var jqxhr = wrapper.search({resource_id: dataset.id, limit: 0});
  21. jqxhr.done(function(results) {
  22. // map ckan types to our usual types ...
  23. var fields = _.map(results.result.fields, function(field) {
  24. field.type = field.type in CKAN_TYPES_MAP ? CKAN_TYPES_MAP[field.type] : field.type;
  25. return field;
  26. });
  27. var out = {
  28. fields: fields,
  29. useMemoryStore: false
  30. };
  31. dfd.resolve(out);
  32. });
  33. return dfd.promise();
  34. };
  35. // only put in the module namespace so we can access for tests!
  36. my._normalizeQuery = function(queryObj, dataset) {
  37. var actualQuery = {
  38. resource_id: dataset.id,
  39. q: queryObj.q,
  40. limit: queryObj.size || 10,
  41. offset: queryObj.from || 0
  42. };
  43. if (queryObj.sort && queryObj.sort.length > 0) {
  44. var _tmp = _.map(queryObj.sort, function(sortObj) {
  45. return sortObj.field + ' ' + (sortObj.order || '');
  46. });
  47. actualQuery.sort = _tmp.join(',');
  48. }
  49. return actualQuery;
  50. }
  51. my.query = function(queryObj, dataset) {
  52. var actualQuery = my._normalizeQuery(queryObj, dataset);
  53. var wrapper = my.DataStore();
  54. var dfd = $.Deferred();
  55. var jqxhr = wrapper.search(actualQuery);
  56. jqxhr.done(function(results) {
  57. var out = {
  58. total: results.result.total,
  59. hits: results.result.records,
  60. };
  61. dfd.resolve(out);
  62. });
  63. return dfd.promise();
  64. };
  65. // ### DataStore
  66. //
  67. // Simple wrapper around the CKAN DataStore API
  68. //
  69. // @param endpoint: CKAN api endpoint (e.g. http://datahub.io/api)
  70. my.DataStore = function(endpoint) {
  71. var that = {
  72. endpoint: endpoint || my.API_ENDPOINT
  73. };
  74. that.search = function(data) {
  75. var searchUrl = that.endpoint + '/3/action/datastore_search';
  76. var jqxhr = $.ajax({
  77. url: searchUrl,
  78. data: data,
  79. dataType: 'json'
  80. });
  81. return jqxhr;
  82. }
  83. return that;
  84. }
  85. var CKAN_TYPES_MAP = {
  86. 'int4': 'integer',
  87. 'int8': 'integer',
  88. 'float8': 'float',
  89. 'text': 'string',
  90. 'json': 'object',
  91. 'timestamp': 'date'
  92. };
  93. }(jQuery, this.recline.Backend.Ckan));
  94. this.recline = this.recline || {};
  95. this.recline.Backend = this.recline.Backend || {};
  96. this.recline.Backend.CSV = this.recline.Backend.CSV || {};
  97. (function(my) {
  98. // ## fetch
  99. //
  100. // 3 options
  101. //
  102. // 1. CSV local fileobject -> HTML5 file object + CSV parser
  103. // 2. Already have CSV string (in data) attribute -> CSV parser
  104. // 2. online CSV file that is ajax-able -> ajax + csv parser
  105. //
  106. // All options generates similar data and give a memory store outcome
  107. my.fetch = function(dataset) {
  108. var dfd = $.Deferred();
  109. if (dataset.file) {
  110. var reader = new FileReader();
  111. var encoding = dataset.encoding || 'UTF-8';
  112. reader.onload = function(e) {
  113. var rows = my.parseCSV(e.target.result, dataset);
  114. dfd.resolve({
  115. records: rows,
  116. metadata: {
  117. filename: dataset.file.name
  118. },
  119. useMemoryStore: true
  120. });
  121. };
  122. reader.onerror = function (e) {
  123. alert('Failed to load file. Code: ' + e.target.error.code);
  124. };
  125. reader.readAsText(dataset.file, encoding);
  126. } else if (dataset.data) {
  127. var rows = my.parseCSV(dataset.data, dataset);
  128. dfd.resolve({
  129. records: rows,
  130. useMemoryStore: true
  131. });
  132. } else if (dataset.url) {
  133. $.get(dataset.url).done(function(data) {
  134. var rows = my.parseCSV(data, dataset);
  135. dfd.resolve({
  136. records: rows,
  137. useMemoryStore: true
  138. });
  139. });
  140. }
  141. return dfd.promise();
  142. };
  143. // Converts a Comma Separated Values string into an array of arrays.
  144. // Each line in the CSV becomes an array.
  145. //
  146. // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
  147. //
  148. // @return The CSV parsed as an array
  149. // @type Array
  150. //
  151. // @param {String} s The string to convert
  152. // @param {Object} options Options for loading CSV including
  153. // @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported
  154. // @param {String} [separator=','] Separator for CSV file
  155. // Heavily based on uselesscode's JS CSV parser (MIT Licensed):
  156. // http://www.uselesscode.org/javascript/csv/
  157. my.parseCSV= function(s, options) {
  158. // Get rid of any trailing \n
  159. s = chomp(s);
  160. var options = options || {};
  161. var trm = (options.trim === false) ? false : true;
  162. var separator = options.separator || ',';
  163. var delimiter = options.delimiter || '"';
  164. var cur = '', // The character we are currently processing.
  165. inQuote = false,
  166. fieldQuoted = false,
  167. field = '', // Buffer for building up the current field
  168. row = [],
  169. out = [],
  170. i,
  171. processField;
  172. processField = function (field) {
  173. if (fieldQuoted !== true) {
  174. // If field is empty set to null
  175. if (field === '') {
  176. field = null;
  177. // If the field was not quoted and we are trimming fields, trim it
  178. } else if (trm === true) {
  179. field = trim(field);
  180. }
  181. // Convert unquoted numbers to their appropriate types
  182. if (rxIsInt.test(field)) {
  183. field = parseInt(field, 10);
  184. } else if (rxIsFloat.test(field)) {
  185. field = parseFloat(field, 10);
  186. }
  187. }
  188. return field;
  189. };
  190. for (i = 0; i < s.length; i += 1) {
  191. cur = s.charAt(i);
  192. // If we are at a EOF or EOR
  193. if (inQuote === false && (cur === separator || cur === "\n")) {
  194. field = processField(field);
  195. // Add the current field to the current row
  196. row.push(field);
  197. // If this is EOR append row to output and flush row
  198. if (cur === "\n") {
  199. out.push(row);
  200. row = [];
  201. }
  202. // Flush the field buffer
  203. field = '';
  204. fieldQuoted = false;
  205. } else {
  206. // If it's not a delimiter, add it to the field buffer
  207. if (cur !== delimiter) {
  208. field += cur;
  209. } else {
  210. if (!inQuote) {
  211. // We are not in a quote, start a quote
  212. inQuote = true;
  213. fieldQuoted = true;
  214. } else {
  215. // Next char is delimiter, this is an escaped delimiter
  216. if (s.charAt(i + 1) === delimiter) {
  217. field += delimiter;
  218. // Skip the next char
  219. i += 1;
  220. } else {
  221. // It's not escaping, so end quote
  222. inQuote = false;
  223. }
  224. }
  225. }
  226. }
  227. }
  228. // Add the last field
  229. field = processField(field);
  230. row.push(field);
  231. out.push(row);
  232. return out;
  233. };
  234. // Converts an array of arrays into a Comma Separated Values string.
  235. // Each array becomes a line in the CSV.
  236. //
  237. // Nulls are converted to empty fields and integers or floats are converted to non-quoted numbers.
  238. //
  239. // @return The array serialized as a CSV
  240. // @type String
  241. //
  242. // @param {Array} a The array of arrays to convert
  243. // @param {Object} options Options for loading CSV including
  244. // @param {String} [separator=','] Separator for CSV file
  245. // Heavily based on uselesscode's JS CSV parser (MIT Licensed):
  246. // http://www.uselesscode.org/javascript/csv/
  247. my.serializeCSV= function(a, options) {
  248. var options = options || {};
  249. var separator = options.separator || ',';
  250. var delimiter = options.delimiter || '"';
  251. var cur = '', // The character we are currently processing.
  252. field = '', // Buffer for building up the current field
  253. row = '',
  254. out = '',
  255. i,
  256. j,
  257. processField;
  258. processField = function (field) {
  259. if (field === null) {
  260. // If field is null set to empty string
  261. field = '';
  262. } else if (typeof field === "string" && rxNeedsQuoting.test(field)) {
  263. // Convert string to delimited string
  264. field = delimiter + field + delimiter;
  265. } else if (typeof field === "number") {
  266. // Convert number to string
  267. field = field.toString(10);
  268. }
  269. return field;
  270. };
  271. for (i = 0; i < a.length; i += 1) {
  272. cur = a[i];
  273. for (j = 0; j < cur.length; j += 1) {
  274. field = processField(cur[j]);
  275. // If this is EOR append row to output and flush row
  276. if (j === (cur.length - 1)) {
  277. row += field;
  278. out += row + "\n";
  279. row = '';
  280. } else {
  281. // Add the current field to the current row
  282. row += field + separator;
  283. }
  284. // Flush the field buffer
  285. field = '';
  286. }
  287. }
  288. return out;
  289. };
  290. var rxIsInt = /^\d+$/,
  291. rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
  292. // If a string has leading or trailing space,
  293. // contains a comma double quote or a newline
  294. // it needs to be quoted in CSV output
  295. rxNeedsQuoting = /^\s|\s$|,|"|\n/,
  296. trim = (function () {
  297. // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
  298. if (String.prototype.trim) {
  299. return function (s) {
  300. return s.trim();
  301. };
  302. } else {
  303. return function (s) {
  304. return s.replace(/^\s*/, '').replace(/\s*$/, '');
  305. };
  306. }
  307. }());
  308. function chomp(s) {
  309. if (s.charAt(s.length - 1) !== "\n") {
  310. // Does not end with \n, just return string
  311. return s;
  312. } else {
  313. // Remove the \n
  314. return s.substring(0, s.length - 1);
  315. }
  316. }
  317. }(this.recline.Backend.CSV));
  318. this.recline = this.recline || {};
  319. this.recline.Backend = this.recline.Backend || {};
  320. this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
  321. (function($, my) {
  322. my.__type__ = 'dataproxy';
  323. // URL for the dataproxy
  324. my.dataproxy_url = 'http://jsonpdataproxy.appspot.com';
  325. // Timeout for dataproxy (after this time if no response we error)
  326. // Needed because use JSONP so do not receive e.g. 500 errors
  327. my.timeout = 5000;
  328. // ## load
  329. //
  330. // Load data from a URL via the [DataProxy](http://github.com/okfn/dataproxy).
  331. //
  332. // Returns array of field names and array of arrays for records
  333. my.fetch = function(dataset) {
  334. var data = {
  335. url: dataset.url,
  336. 'max-results': dataset.size || dataset.rows || 1000,
  337. type: dataset.format || ''
  338. };
  339. var jqxhr = $.ajax({
  340. url: my.dataproxy_url,
  341. data: data,
  342. dataType: 'jsonp'
  343. });
  344. var dfd = $.Deferred();
  345. _wrapInTimeout(jqxhr).done(function(results) {
  346. if (results.error) {
  347. dfd.reject(results.error);
  348. }
  349. dfd.resolve({
  350. records: results.data,
  351. fields: results.fields,
  352. useMemoryStore: true
  353. });
  354. })
  355. .fail(function(arguments) {
  356. dfd.reject(arguments);
  357. });
  358. return dfd.promise();
  359. };
  360. // ## _wrapInTimeout
  361. //
  362. // Convenience method providing a crude way to catch backend errors on JSONP calls.
  363. // Many of backends use JSONP and so will not get error messages and this is
  364. // a crude way to catch those errors.
  365. var _wrapInTimeout = function(ourFunction) {
  366. var dfd = $.Deferred();
  367. var timer = setTimeout(function() {
  368. dfd.reject({
  369. message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds'
  370. });
  371. }, my.timeout);
  372. ourFunction.done(function(arguments) {
  373. clearTimeout(timer);
  374. dfd.resolve(arguments);
  375. })
  376. .fail(function(arguments) {
  377. clearTimeout(timer);
  378. dfd.reject(arguments);
  379. })
  380. ;
  381. return dfd.promise();
  382. }
  383. }(jQuery, this.recline.Backend.DataProxy));
  384. this.recline = this.recline || {};
  385. this.recline.Backend = this.recline.Backend || {};
  386. this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {};
  387. (function($, my) {
  388. my.__type__ = 'elasticsearch';
  389. // ## ElasticSearch Wrapper
  390. //
  391. // A simple JS wrapper around an [ElasticSearch](http://www.elasticsearch.org/) endpoints.
  392. //
  393. // @param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running
  394. // on http://localhost:9200 with index twitter and type tweet it would be:
  395. //
  396. // <pre>http://localhost:9200/twitter/tweet</pre>
  397. //
  398. // @param {Object} options: set of options such as:
  399. //
  400. // * headers - {dict of headers to add to each request}
  401. // * dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests)
  402. my.Wrapper = function(endpoint, options) {
  403. var self = this;
  404. this.endpoint = endpoint;
  405. this.options = _.extend({
  406. dataType: 'json'
  407. },
  408. options);
  409. // ### mapping
  410. //
  411. // Get ES mapping for this type/table
  412. //
  413. // @return promise compatible deferred object.
  414. this.mapping = function() {
  415. var schemaUrl = self.endpoint + '/_mapping';
  416. var jqxhr = makeRequest({
  417. url: schemaUrl,
  418. dataType: this.options.dataType
  419. });
  420. return jqxhr;
  421. };
  422. // ### get
  423. //
  424. // Get record corresponding to specified id
  425. //
  426. // @return promise compatible deferred object.
  427. this.get = function(id) {
  428. var base = this.endpoint + '/' + id;
  429. return makeRequest({
  430. url: base,
  431. dataType: 'json'
  432. });
  433. };
  434. // ### upsert
  435. //
  436. // create / update a record to ElasticSearch backend
  437. //
  438. // @param {Object} doc an object to insert to the index.
  439. // @return deferred supporting promise API
  440. this.upsert = function(doc) {
  441. var data = JSON.stringify(doc);
  442. url = this.endpoint;
  443. if (doc.id) {
  444. url += '/' + doc.id;
  445. }
  446. return makeRequest({
  447. url: url,
  448. type: 'POST',
  449. data: data,
  450. dataType: 'json'
  451. });
  452. };
  453. // ### delete
  454. //
  455. // Delete a record from the ElasticSearch backend.
  456. //
  457. // @param {Object} id id of object to delete
  458. // @return deferred supporting promise API
  459. this.delete = function(id) {
  460. url = this.endpoint;
  461. url += '/' + id;
  462. return makeRequest({
  463. url: url,
  464. type: 'DELETE',
  465. dataType: 'json'
  466. });
  467. };
  468. this._normalizeQuery = function(queryObj) {
  469. var self = this;
  470. var queryInfo = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
  471. var out = {
  472. constant_score: {
  473. query: {}
  474. }
  475. };
  476. if (!queryInfo.q) {
  477. out.constant_score.query = {
  478. match_all: {}
  479. };
  480. } else {
  481. out.constant_score.query = {
  482. query_string: {
  483. query: queryInfo.q
  484. }
  485. };
  486. }
  487. if (queryInfo.filters && queryInfo.filters.length) {
  488. out.constant_score.filter = {
  489. and: []
  490. };
  491. _.each(queryInfo.filters, function(filter) {
  492. out.constant_score.filter.and.push(self._convertFilter(filter));
  493. });
  494. }
  495. return out;
  496. },
  497. // convert from Recline sort structure to ES form
  498. // http://www.elasticsearch.org/guide/reference/api/search/sort.html
  499. this._normalizeSort = function(sort) {
  500. var out = _.map(sort, function(sortObj) {
  501. var _tmp = {};
  502. var _tmp2 = _.clone(sortObj);
  503. delete _tmp2['field'];
  504. _tmp[sortObj.field] = _tmp2;
  505. return _tmp;
  506. });
  507. return out;
  508. },
  509. this._convertFilter = function(filter) {
  510. var out = {};
  511. out[filter.type] = {}
  512. if (filter.type === 'term') {
  513. out.term[filter.field] = filter.term.toLowerCase();
  514. } else if (filter.type === 'geo_distance') {
  515. out.geo_distance[filter.field] = filter.point;
  516. out.geo_distance.distance = filter.distance;
  517. out.geo_distance.unit = filter.unit;
  518. }
  519. return out;
  520. },
  521. // ### query
  522. //
  523. // @return deferred supporting promise API
  524. this.query = function(queryObj) {
  525. var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
  526. esQuery.query = this._normalizeQuery(queryObj);
  527. delete esQuery.q;
  528. delete esQuery.filters;
  529. if (esQuery.sort && esQuery.sort.length > 0) {
  530. esQuery.sort = this._normalizeSort(esQuery.sort);
  531. }
  532. var data = {source: JSON.stringify(esQuery)};
  533. var url = this.endpoint + '/_search';
  534. var jqxhr = makeRequest({
  535. url: url,
  536. data: data,
  537. dataType: this.options.dataType
  538. });
  539. return jqxhr;
  540. }
  541. };
  542. // ## Recline Connectors
  543. //
  544. // Requires URL of ElasticSearch endpoint to be specified on the dataset
  545. // via the url attribute.
  546. // ES options which are passed through to `options` on Wrapper (see Wrapper for details)
  547. my.esOptions = {};
  548. // ### fetch
  549. my.fetch = function(dataset) {
  550. var es = new my.Wrapper(dataset.url, my.esOptions);
  551. var dfd = $.Deferred();
  552. es.mapping().done(function(schema) {
  553. if (!schema){
  554. dfd.reject({'message':'Elastic Search did not return a mapping'});
  555. return;
  556. }
  557. // only one top level key in ES = the type so we can ignore it
  558. var key = _.keys(schema)[0];
  559. var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
  560. dict.id = fieldName;
  561. return dict;
  562. });
  563. dfd.resolve({
  564. fields: fieldData
  565. });
  566. })
  567. .fail(function(arguments) {
  568. dfd.reject(arguments);
  569. });
  570. return dfd.promise();
  571. };
  572. // ### save
  573. my.save = function(changes, dataset) {
  574. var es = new my.Wrapper(dataset.url, my.esOptions);
  575. if (changes.creates.length + changes.updates.length + changes.deletes.length > 1) {
  576. var dfd = $.Deferred();
  577. msg = 'Saving more than one item at a time not yet supported';
  578. alert(msg);
  579. dfd.reject(msg);
  580. return dfd.promise();
  581. }
  582. if (changes.creates.length > 0) {
  583. return es.upsert(changes.creates[0]);
  584. }
  585. else if (changes.updates.length >0) {
  586. return es.upsert(changes.updates[0]);
  587. } else if (changes.deletes.length > 0) {
  588. return es.delete(changes.deletes[0].id);
  589. }
  590. };
  591. // ### query
  592. my.query = function(queryObj, dataset) {
  593. var dfd = $.Deferred();
  594. var es = new my.Wrapper(dataset.url, my.esOptions);
  595. var jqxhr = es.query(queryObj);
  596. jqxhr.done(function(results) {
  597. var out = {
  598. total: results.hits.total,
  599. };
  600. out.hits = _.map(results.hits.hits, function(hit) {
  601. if (!('id' in hit._source) && hit._id) {
  602. hit._source.id = hit._id;
  603. }
  604. return hit._source;
  605. });
  606. if (results.facets) {
  607. out.facets = results.facets;
  608. }
  609. dfd.resolve(out);
  610. }).fail(function(errorObj) {
  611. var out = {
  612. title: 'Failed: ' + errorObj.status + ' code',
  613. message: errorObj.responseText
  614. };
  615. dfd.reject(out);
  616. });
  617. return dfd.promise();
  618. };
  619. // ### makeRequest
  620. //
  621. // Just $.ajax but in any headers in the 'headers' attribute of this
  622. // Backend instance. Example:
  623. //
  624. // <pre>
  625. // var jqxhr = this._makeRequest({
  626. // url: the-url
  627. // });
  628. // </pre>
  629. var makeRequest = function(data, headers) {
  630. var extras = {};
  631. if (headers) {
  632. extras = {
  633. beforeSend: function(req) {
  634. _.each(headers, function(value, key) {
  635. req.setRequestHeader(key, value);
  636. });
  637. }
  638. };
  639. }
  640. var data = _.extend(extras, data);
  641. return $.ajax(data);
  642. };
  643. }(jQuery, this.recline.Backend.ElasticSearch));
  644. this.recline = this.recline || {};
  645. this.recline.Backend = this.recline.Backend || {};
  646. this.recline.Backend.GDocs = this.recline.Backend.GDocs || {};
  647. (function($, my) {
  648. my.__type__ = 'gdocs';
  649. // ## Google spreadsheet backend
  650. //
  651. // Fetch data from a Google Docs spreadsheet.
  652. //
  653. // Dataset must have a url attribute pointing to the Gdocs or its JSON feed e.g.
  654. // <pre>
  655. // var dataset = new recline.Model.Dataset({
  656. // url: 'https://docs.google.com/spreadsheet/ccc?key=0Aon3JiuouxLUdGlQVDJnbjZRSU1tUUJWOUZXRG53VkE#gid=0'
  657. // },
  658. // 'gdocs'
  659. // );
  660. //
  661. // var dataset = new recline.Model.Dataset({
  662. // url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
  663. // },
  664. // 'gdocs'
  665. // );
  666. // </pre>
  667. //
  668. // @return object with two attributes
  669. //
  670. // * fields: array of Field objects
  671. // * records: array of objects for each row
  672. my.fetch = function(dataset) {
  673. var dfd = $.Deferred();
  674. var urls = my.getGDocsAPIUrls(dataset.url);
  675. // TODO cover it with tests
  676. // get the spreadsheet title
  677. (function () {
  678. var titleDfd = $.Deferred();
  679. $.getJSON(urls.spreadsheet, function (d) {
  680. titleDfd.resolve({
  681. spreadsheetTitle: d.feed.title.$t
  682. });
  683. });
  684. return titleDfd.promise();
  685. }()).then(function (response) {
  686. // get the actual worksheet data
  687. $.getJSON(urls.worksheet, function(d) {
  688. var result = my.parseData(d);
  689. var fields = _.map(result.fields, function(fieldId) {
  690. return {id: fieldId};
  691. });
  692. dfd.resolve({
  693. metadata: {
  694. title: response.spreadsheetTitle +" :: "+ result.worksheetTitle,
  695. spreadsheetTitle: response.spreadsheetTitle,
  696. worksheetTitle : result.worksheetTitle
  697. },
  698. records : result.records,
  699. fields : fields,
  700. useMemoryStore: true
  701. });
  702. });
  703. });
  704. return dfd.promise();
  705. };
  706. // ## parseData
  707. //
  708. // Parse data from Google Docs API into a reasonable form
  709. //
  710. // :options: (optional) optional argument dictionary:
  711. // columnsToUse: list of columns to use (specified by field names)
  712. // colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion).
  713. // :return: tabular data object (hash with keys: field and data).
  714. //
  715. // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.
  716. my.parseData = function(gdocsSpreadsheet, options) {
  717. var options = options || {};
  718. var colTypes = options.colTypes || {};
  719. var results = {
  720. fields : [],
  721. records: []
  722. };
  723. var entries = gdocsSpreadsheet.feed.entry || [];
  724. var key;
  725. var colName;
  726. // percentage values (e.g. 23.3%)
  727. var rep = /^([\d\.\-]+)\%$/;
  728. for(key in entries[0]) {
  729. // it's barely possible it has inherited keys starting with 'gsx$'
  730. if(/^gsx/.test(key)) {
  731. colName = key.substr(4);
  732. results.fields.push(colName);
  733. }
  734. }
  735. // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])
  736. results.records = _.map(entries, function(entry) {
  737. var row = {};
  738. _.each(results.fields, function(col) {
  739. var _keyname = 'gsx$' + col;
  740. var value = entry[_keyname].$t;
  741. var num;
  742. // TODO cover this part of code with test
  743. // TODO use the regexp only once
  744. // if labelled as % and value contains %, convert
  745. if(colTypes[col] === 'percent' && rep.test(value)) {
  746. num = rep.exec(value)[1];
  747. value = parseFloat(num) / 100;
  748. }
  749. row[col] = value;
  750. });
  751. return row;
  752. });
  753. results.worksheetTitle = gdocsSpreadsheet.feed.title.$t;
  754. return results;
  755. };
  756. // Convenience function to get GDocs JSON API Url from standard URL
  757. my.getGDocsAPIUrls = function(url) {
  758. // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=YYY
  759. var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*gid=([\d]+).*/;
  760. var matches = url.match(regex);
  761. var key;
  762. var worksheet;
  763. var urls;
  764. if(!!matches) {
  765. key = matches[1];
  766. // the gid in url is 0-based and feed url is 1-based
  767. worksheet = parseInt(matches[2]) + 1;
  768. urls = {
  769. worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
  770. spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
  771. }
  772. }
  773. else {
  774. // we assume that it's one of the feeds urls
  775. key = url.split('/')[5];
  776. // by default then, take first worksheet
  777. worksheet = 1;
  778. urls = {
  779. worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
  780. spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
  781. }
  782. }
  783. return urls;
  784. };
  785. }(jQuery, this.recline.Backend.GDocs));
  786. this.recline = this.recline || {};
  787. this.recline.Backend = this.recline.Backend || {};
  788. this.recline.Backend.Memory = this.recline.Backend.Memory || {};
  789. (function($, my) {
  790. my.__type__ = 'memory';
  791. // ## Data Wrapper
  792. //
  793. // Turn a simple array of JS objects into a mini data-store with
  794. // functionality like querying, faceting, updating (by ID) and deleting (by
  795. // ID).
  796. //
  797. // @param data list of hashes for each record/row in the data ({key:
  798. // value, key: value})
  799. // @param fields (optional) list of field hashes (each hash defining a field
  800. // as per recline.Model.Field). If fields not specified they will be taken
  801. // from the data.
  802. my.Store = function(data, fields) {
  803. var self = this;
  804. this.data = data;
  805. if (fields) {
  806. this.fields = fields;
  807. } else {
  808. if (data) {
  809. this.fields = _.map(data[0], function(value, key) {
  810. return {id: key};
  811. });
  812. }
  813. }
  814. this.update = function(doc) {
  815. _.each(self.data, function(internalDoc, idx) {
  816. if(doc.id === internalDoc.id) {
  817. self.data[idx] = doc;
  818. }
  819. });
  820. };
  821. this.delete = function(doc) {
  822. var newdocs = _.reject(self.data, function(internalDoc) {
  823. return (doc.id === internalDoc.id);
  824. });
  825. this.data = newdocs;
  826. };
  827. this.save = function(changes, dataset) {
  828. var self = this;
  829. var dfd = $.Deferred();
  830. // TODO _.each(changes.creates) { ... }
  831. _.each(changes.updates, function(record) {
  832. self.update(record);
  833. });
  834. _.each(changes.deletes, function(record) {
  835. self.delete(record);
  836. });
  837. dfd.resolve();
  838. return dfd.promise();
  839. },
  840. this.query = function(queryObj) {
  841. var dfd = $.Deferred();
  842. var numRows = queryObj.size || this.data.length;
  843. var start = queryObj.from || 0;
  844. var results = this.data;
  845. results = this._applyFilters(results, queryObj);
  846. results = this._applyFreeTextQuery(results, queryObj);
  847. // TODO: this is not complete sorting!
  848. // What's wrong is we sort on the *last* entry in the sort list if there are multiple sort criteria
  849. _.each(queryObj.sort, function(sortObj) {
  850. var fieldName = sortObj.field;
  851. results = _.sortBy(results, function(doc) {
  852. var _out = doc[fieldName];
  853. return _out;
  854. });
  855. if (sortObj.order == 'desc') {
  856. results.reverse();
  857. }
  858. });
  859. var facets = this.computeFacets(results, queryObj);
  860. var out = {
  861. total: results.length,
  862. hits: results.slice(start, start+numRows),
  863. facets: facets
  864. };
  865. dfd.resolve(out);
  866. return dfd.promise();
  867. };
  868. // in place filtering
  869. this._applyFilters = function(results, queryObj) {
  870. var filters = queryObj.filters;
  871. // register filters
  872. var filterFunctions = {
  873. term : term,
  874. range : range,
  875. geo_distance : geo_distance
  876. };
  877. var dataParsers = {
  878. number : function (e) { return parseFloat(e, 10); },
  879. string : function (e) { return e.toString() },
  880. date : function (e) { return new Date(e).valueOf() }
  881. };
  882. // filter records
  883. return _.filter(results, function (record) {
  884. var passes = _.map(filters, function (filter) {
  885. return filterFunctions[filter.type](record, filter);
  886. });
  887. // return only these records that pass all filters
  888. return _.all(passes, _.identity);
  889. });
  890. // filters definitions
  891. function term(record, filter) {
  892. var parse = dataParsers[filter.fieldType];
  893. var value = parse(record[filter.field]);
  894. var term = parse(filter.term);
  895. return (value === term);
  896. }
  897. function range(record, filter) {
  898. var parse = dataParsers[filter.fieldType];
  899. var value = parse(record[filter.field]);
  900. var start = parse(filter.start);
  901. var stop = parse(filter.stop);
  902. return (value >= start && value <= stop);
  903. }
  904. function geo_distance() {
  905. // TODO code here
  906. }
  907. };
  908. // we OR across fields but AND across terms in query string
  909. this._applyFreeTextQuery = function(results, queryObj) {
  910. if (queryObj.q) {
  911. var terms = queryObj.q.split(' ');
  912. results = _.filter(results, function(rawdoc) {
  913. var matches = true;
  914. _.each(terms, function(term) {
  915. var foundmatch = false;
  916. _.each(self.fields, function(field) {
  917. var value = rawdoc[field.id];
  918. if (value !== null) {
  919. value = value.toString();
  920. } else {
  921. // value can be null (apparently in some cases)
  922. value = '';
  923. }
  924. // TODO regexes?
  925. foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase());
  926. // TODO: early out (once we are true should break to spare unnecessary testing)
  927. // if (foundmatch) return true;
  928. });
  929. matches = matches && foundmatch;
  930. // TODO: early out (once false should break to spare unnecessary testing)
  931. // if (!matches) return false;
  932. });
  933. return matches;
  934. });
  935. }
  936. return results;
  937. };
  938. this.computeFacets = function(records, queryObj) {
  939. var facetResults = {};
  940. if (!queryObj.facets) {
  941. return facetResults;
  942. }
  943. _.each(queryObj.facets, function(query, facetId) {
  944. // TODO: remove dependency on recline.Model
  945. facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
  946. facetResults[facetId].termsall = {};
  947. });
  948. // faceting
  949. _.each(records, function(doc) {
  950. _.each(queryObj.facets, function(query, facetId) {
  951. var fieldId = query.terms.field;
  952. var val = doc[fieldId];
  953. var tmp = facetResults[facetId];
  954. if (val) {
  955. tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1;
  956. } else {
  957. tmp.missing = tmp.missing + 1;
  958. }
  959. });
  960. });
  961. _.each(queryObj.facets, function(query, facetId) {
  962. var tmp = facetResults[facetId];
  963. var terms = _.map(tmp.termsall, function(count, term) {
  964. return { term: term, count: count };
  965. });
  966. tmp.terms = _.sortBy(terms, function(item) {
  967. // want descending order
  968. return -item.count;
  969. });
  970. tmp.terms = tmp.terms.slice(0, 10);
  971. });
  972. return facetResults;
  973. };
  974. this.transform = function(editFunc) {
  975. var toUpdate = recline.Data.Transform.mapDocs(this.data, editFunc);
  976. // TODO: very inefficient -- could probably just walk the documents and updates in tandem and update
  977. _.each(toUpdate.updates, function(record, idx) {
  978. self.data[idx] = record;
  979. });
  980. return this.save(toUpdate);
  981. };
  982. };
  983. }(jQuery, this.recline.Backend.Memory));
  984. this.recline = this.recline || {};
  985. this.recline.Data = this.recline.Data || {};
  986. (function(my) {
  987. // adapted from https://github.com/harthur/costco. heather rules
  988. my.Transform = {};
  989. my.Transform.evalFunction = function(funcString) {
  990. try {
  991. eval("var editFunc = " + funcString);
  992. } catch(e) {
  993. return {errorMessage: e+""};
  994. }
  995. return editFunc;
  996. };
  997. my.Transform.previewTransform = function(docs, editFunc, currentColumn) {
  998. var preview = [];
  999. var updated = my.Transform.mapDocs($.extend(true, {}, docs), editFunc);
  1000. for (var i = 0; i < updated.docs.length; i++) {
  1001. var before = docs[i]
  1002. , after = updated.docs[i]
  1003. ;
  1004. if (!after) after = {};
  1005. if (currentColumn) {
  1006. preview.push({before: before[currentColumn], after: after[currentColumn]});
  1007. } else {
  1008. preview.push({before: before, after: after});
  1009. }
  1010. }
  1011. return preview;
  1012. };
  1013. my.Transform.mapDocs = function(docs, editFunc) {
  1014. var edited = []
  1015. , deleted = []
  1016. , failed = []
  1017. ;
  1018. var updatedDocs = _.map(docs, function(doc) {
  1019. try {
  1020. var updated = editFunc(_.clone(doc));
  1021. } catch(e) {
  1022. failed.push(doc);
  1023. return;
  1024. }
  1025. if(updated === null) {
  1026. updated = {_deleted: true};
  1027. edited.push(updated);
  1028. deleted.push(doc);
  1029. }
  1030. else if(updated && !_.isEqual(updated, doc)) {
  1031. edited.push(updated);
  1032. }
  1033. return updated;
  1034. });
  1035. return {
  1036. updates: edited,
  1037. docs: updatedDocs,
  1038. deletes: deleted,
  1039. failed: failed
  1040. };
  1041. };
  1042. }(this.recline.Data))
  1043. // # Recline Backbone Models
  1044. this.recline = this.recline || {};
  1045. this.recline.Model = this.recline.Model || {};
  1046. (function($, my) {
  1047. // ## <a id="dataset">Dataset</a>
  1048. my.Dataset = Backbone.Model.extend({
  1049. constructor: function Dataset() {
  1050. Backbone.Model.prototype.constructor.apply(this, arguments);
  1051. },
  1052. // ### initialize
  1053. initialize: function() {
  1054. _.bindAll(this, 'query');
  1055. this.backend = null;
  1056. if (this.get('backend')) {
  1057. this.backend = this._backendFromString(this.get('backend'));
  1058. } else { // try to guess backend ...
  1059. if (this.get('records')) {
  1060. this.backend = recline.Backend.Memory;
  1061. }
  1062. }
  1063. this.fields = new my.FieldList();
  1064. this.records = new my.RecordList();
  1065. this._changes = {
  1066. deletes: [],
  1067. updates: [],
  1068. creates: []
  1069. };
  1070. this.facets = new my.FacetList();
  1071. this.recordCount = null;
  1072. this.queryState = new my.Query();
  1073. this.queryState.bind('change', this.query);
  1074. this.queryState.bind('facet:add', this.query);
  1075. // store is what we query and save against
  1076. // store will either be the backend or be a memory store if Backend fetch
  1077. // tells us to use memory store
  1078. this._store = this.backend;
  1079. if (this.backend == recline.Backend.Memory) {
  1080. this.fetch();
  1081. }
  1082. },
  1083. // ### fetch
  1084. //
  1085. // Retrieve dataset and (some) records from the backend.
  1086. fetch: function() {
  1087. var self = this;
  1088. var dfd = $.Deferred();
  1089. if (this.backend !== recline.Backend.Memory) {
  1090. this.backend.fetch(this.toJSON())
  1091. .done(handleResults)
  1092. .fail(function(arguments) {
  1093. dfd.reject(arguments);
  1094. });
  1095. } else {
  1096. // special case where we have been given data directly
  1097. handleResults({
  1098. records: this.get('records'),
  1099. fields: this.get('fields'),
  1100. useMemoryStore: true
  1101. });
  1102. }
  1103. function handleResults(results) {
  1104. var out = self._normalizeRecordsAndFields(results.records, results.fields);
  1105. if (results.useMemoryStore) {
  1106. self._store = new recline.Backend.Memory.Store(out.records, out.fields);
  1107. }
  1108. self.set(results.metadata);
  1109. self.fields.reset(out.fields);
  1110. self.query()
  1111. .done(function() {
  1112. dfd.resolve(self);
  1113. })
  1114. .fail(function(arguments) {
  1115. dfd.reject(arguments);
  1116. });
  1117. }
  1118. return dfd.promise();
  1119. },
  1120. // ### _normalizeRecordsAndFields
  1121. //
  1122. // 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
  1123. //
  1124. // e.g. fields = ['a', 'b', 'c'] and records = [ [1,2,3] ] =>
  1125. // fields = [ {id: a}, {id: b}, {id: c}], records = [ {a: 1}, {b: 2}, {c: 3}]
  1126. _normalizeRecordsAndFields: function(records, fields) {
  1127. // if no fields get them from records
  1128. if (!fields && records && records.length > 0) {
  1129. // records is array then fields is first row of records ...
  1130. if (records[0] instanceof Array) {
  1131. fields = records[0];
  1132. records = records.slice(1);
  1133. } else {
  1134. fields = _.map(_.keys(records[0]), function(key) {
  1135. return {id: key};
  1136. });
  1137. }
  1138. }
  1139. // fields is an array of strings (i.e. list of field headings/ids)
  1140. if (fields && fields.length > 0 && typeof fields[0] === 'string') {
  1141. // Rename duplicate fieldIds as each field name needs to be
  1142. // unique.
  1143. var seen = {};
  1144. fields = _.map(fields, function(field, index) {
  1145. // cannot use trim as not supported by IE7
  1146. var fieldId = field.replace(/^\s+|\s+$/g, '');
  1147. if (fieldId === '') {
  1148. fieldId = '_noname_';
  1149. field = fieldId;
  1150. }
  1151. while (fieldId in seen) {
  1152. seen[field] += 1;
  1153. fieldId = field + seen[field];
  1154. }
  1155. if (!(field in seen)) {
  1156. seen[field] = 0;
  1157. }
  1158. // TODO: decide whether to keep original name as label ...
  1159. // return { id: fieldId, label: field || fieldId }
  1160. return { id: fieldId };
  1161. });
  1162. }
  1163. // records is provided as arrays so need to zip together with fields
  1164. // NB: this requires you to have fields to match arrays
  1165. if (records && records.length > 0 && records[0] instanceof Array) {
  1166. records = _.map(records, function(doc) {
  1167. var tmp = {};
  1168. _.each(fields, function(field, idx) {
  1169. tmp[field.id] = doc[idx];
  1170. });
  1171. return tmp;
  1172. });
  1173. }
  1174. return {
  1175. fields: fields,
  1176. records: records
  1177. };
  1178. },
  1179. save: function() {
  1180. var self = this;
  1181. // TODO: need to reset the changes ...
  1182. return this._store.save(this._changes, this.toJSON());
  1183. },
  1184. transform: function(editFunc) {
  1185. var self = this;
  1186. if (!this._store.transform) {
  1187. alert('Transform is not supported with this backend: ' + this.get('backend'));
  1188. return;
  1189. }
  1190. this.trigger('recline:flash', {message: "Updating all visible docs. This could take a while...", persist: true, loader: true});
  1191. this._store.transform(editFunc).done(function() {
  1192. // reload data as records have changed
  1193. self.query();
  1194. self.trigger('recline:flash', {message: "Records updated successfully"});
  1195. });
  1196. },
  1197. // ### query
  1198. //
  1199. // AJAX method with promise API to get records from the backend.
  1200. //
  1201. // It will query based on current query state (given by this.queryState)
  1202. // updated by queryObj (if provided).
  1203. //
  1204. // Resulting RecordList are used to reset this.records and are
  1205. // also returned.
  1206. query: function(queryObj) {
  1207. var self = this;
  1208. var dfd = $.Deferred();
  1209. this.trigger('query:start');
  1210. if (queryObj) {
  1211. this.queryState.set(queryObj, {silent: true});
  1212. }
  1213. var actualQuery = this.queryState.toJSON();
  1214. this._store.query(actualQuery, this.toJSON())
  1215. .done(function(queryResult) {
  1216. self._handleQueryResult(queryResult);
  1217. self.trigger('query:done');
  1218. dfd.resolve(self.records);
  1219. })
  1220. .fail(function(arguments) {
  1221. self.trigger('query:fail', arguments);
  1222. dfd.reject(arguments);
  1223. });
  1224. return dfd.promise();
  1225. },
  1226. _handleQueryResult: function(queryResult) {
  1227. var self = this;
  1228. self.recordCount = queryResult.total;
  1229. var docs = _.map(queryResult.hits, function(hit) {
  1230. var _doc = new my.Record(hit);
  1231. _doc.fields = self.fields;
  1232. _doc.bind('change', function(doc) {
  1233. self._changes.updates.push(doc.toJSON());
  1234. });
  1235. _doc.bind('destroy', function(doc) {
  1236. self._changes.deletes.push(doc.toJSON());
  1237. });
  1238. return _doc;
  1239. });
  1240. self.records.reset(docs);
  1241. if (queryResult.facets) {
  1242. var facets = _.map(queryResult.facets, function(facetResult, facetId) {
  1243. facetResult.id = facetId;
  1244. return new my.Facet(facetResult);
  1245. });
  1246. self.facets.reset(facets);
  1247. }
  1248. },
  1249. toTemplateJSON: function() {
  1250. var data = this.toJSON();
  1251. data.recordCount = this.recordCount;
  1252. data.fields = this.fields.toJSON();
  1253. return data;
  1254. },
  1255. // ### getFieldsSummary
  1256. //
  1257. // Get a summary for each field in the form of a `Facet`.
  1258. //
  1259. // @return null as this is async function. Provides deferred/promise interface.
  1260. getFieldsSummary: function() {
  1261. var self = this;
  1262. var query = new my.Query();
  1263. query.set({size: 0});
  1264. this.fields.each(function(field) {
  1265. query.addFacet(field.id);
  1266. });
  1267. var dfd = $.Deferred();
  1268. this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) {
  1269. if (queryResult.facets) {
  1270. _.each(queryResult.facets, function(facetResult, facetId) {
  1271. facetResult.id = facetId;
  1272. var facet = new my.Facet(facetResult);
  1273. // TODO: probably want replace rather than reset (i.e. just replace the facet with this id)
  1274. self.fields.get(facetId).facets.reset(facet);
  1275. });
  1276. }
  1277. dfd.resolve(queryResult);
  1278. });
  1279. return dfd.promise();
  1280. },
  1281. // Deprecated (as of v0.5) - use record.summary()
  1282. recordSummary: function(record) {
  1283. return record.summary();
  1284. },
  1285. // ### _backendFromString(backendString)
  1286. //
  1287. // See backend argument to initialize for details
  1288. _backendFromString: function(backendString) {
  1289. var parts = backendString.split('.');
  1290. // walk through the specified path xxx.yyy.zzz to get the final object which should be backend class
  1291. var current = window;
  1292. for(ii=0;ii<parts.length;ii++) {
  1293. if (!current) {
  1294. break;
  1295. }
  1296. current = current[parts[ii]];
  1297. }
  1298. if (current) {
  1299. return current;
  1300. }
  1301. // alternatively we just had a simple string
  1302. var backend = null;
  1303. if (recline && recline.Backend) {
  1304. _.each(_.keys(recline.Backend), function(name) {
  1305. if (name.toLowerCase() === backendString.toLowerCase()) {
  1306. backend = recline.Backend[name];
  1307. }
  1308. });
  1309. }
  1310. return backend;
  1311. }
  1312. });
  1313. // ## <a id="record">A Record</a>
  1314. //
  1315. // A single record (or row) in the dataset
  1316. my.Record = Backbone.Model.extend({
  1317. constructor: function Record() {
  1318. Backbone.Model.prototype.constructor.apply(this, arguments);
  1319. },
  1320. // ### initialize
  1321. //
  1322. // Create a Record
  1323. //
  1324. // You usually will not do this directly but will have records created by
  1325. // Dataset e.g. in query method
  1326. //
  1327. // Certain methods require presence of a fields attribute (identical to that on Dataset)
  1328. initialize: function() {
  1329. _.bindAll(this, 'getFieldValue');
  1330. },
  1331. // ### getFieldValue
  1332. //
  1333. // For the provided Field get the corresponding rendered computed data value
  1334. // for this record.
  1335. getFieldValue: function(field) {
  1336. val = this.getFieldValueUnrendered(field);
  1337. if (field.renderer) {
  1338. val = field.renderer(val, field, this.toJSON());
  1339. }
  1340. return val;
  1341. },
  1342. // ### getFieldValueUnrendered
  1343. //
  1344. // For the provided Field get the corresponding computed data value
  1345. // for this record.
  1346. getFieldValueUnrendered: function(field) {
  1347. var val = this.get(field.id);
  1348. if (field.deriver) {
  1349. val = field.deriver(val, field, this);
  1350. }
  1351. return val;
  1352. },
  1353. // ### summary
  1354. //
  1355. // Get a simple html summary of this record in form of key/value list
  1356. summary: function(record) {
  1357. var self = this;
  1358. var html = '<div class="recline-record-summary">';
  1359. this.fields.each(function(field) {
  1360. if (field.id != 'id') {
  1361. html += '<div class="' + field.id + '"><strong>' + field.get('label') + '</strong>: ' + self.getFieldValue(field) + '</div>';
  1362. }
  1363. });
  1364. html += '</div>';
  1365. return html;
  1366. },
  1367. // Override Backbone save, fetch and destroy so they do nothing
  1368. // Instead, Dataset object that created this Record should take care of
  1369. // handling these changes (discovery will occur via event notifications)
  1370. // WARNING: these will not persist *unless* you call save on Dataset
  1371. fetch: function() {},
  1372. save: function() {},
  1373. destroy: function() { this.trigger('destroy', this); }
  1374. });
  1375. // ## A Backbone collection of Records
  1376. my.RecordList = Backbone.Collection.extend({
  1377. constructor: function RecordList() {
  1378. Backbone.Collection.prototype.constructor.apply(this, arguments);
  1379. },
  1380. model: my.Record
  1381. });
  1382. // ## <a id="field">A Field (aka Column) on a Dataset</a>
  1383. my.Field = Backbone.Model.extend({
  1384. constructor: function Field() {
  1385. Backbone.Model.prototype.constructor.apply(this, arguments);
  1386. },
  1387. // ### defaults - define default values
  1388. defaults: {
  1389. label: null,
  1390. type: 'string',
  1391. format: null,
  1392. is_derived: false
  1393. },
  1394. // ### initialize
  1395. //
  1396. // @param {Object} data: standard Backbone model attributes
  1397. //
  1398. // @param {Object} options: renderer and/or deriver functions.
  1399. initialize: function(data, options) {
  1400. // if a hash not passed in the first argument throw error
  1401. if ('0' in data) {
  1402. throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
  1403. }
  1404. if (this.attributes.label === null) {
  1405. this.set({label: this.id});
  1406. }
  1407. if (options) {
  1408. this.renderer = options.renderer;
  1409. this.deriver = options.deriver;
  1410. }
  1411. if (!this.renderer) {
  1412. this.renderer = this.defaultRenderers[this.get('type')];
  1413. }
  1414. this.facets = new my.FacetList();
  1415. },
  1416. defaultRenderers: {
  1417. object: function(val, field, doc) {
  1418. return JSON.stringify(val);
  1419. },
  1420. geo_point: function(val, field, doc) {
  1421. return JSON.stringify(val);
  1422. },
  1423. 'float': function(val, field, doc) {
  1424. var format = field.get('format');
  1425. if (format === 'percentage') {
  1426. return val + '%';
  1427. }
  1428. return val;
  1429. },
  1430. 'string': function(val, field, doc) {
  1431. var format = field.get('format');
  1432. if (format === 'markdown') {
  1433. if (typeof Showdown !== 'undefined') {
  1434. var showdown = new Showdown.converter();
  1435. out = showdown.makeHtml(val);
  1436. return out;
  1437. } else {
  1438. return val;
  1439. }
  1440. } else if (format == 'plain') {
  1441. return val;
  1442. } else {
  1443. // as this is the default and default type is string may get things
  1444. // here that are not actually strings
  1445. if (val && typeof val === 'string') {
  1446. val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
  1447. }
  1448. return val
  1449. }
  1450. }
  1451. }
  1452. });
  1453. my.FieldList = Backbone.Collection.extend({
  1454. constructor: function FieldList() {
  1455. Backbone.Collection.prototype.constructor.apply(this, arguments);
  1456. },
  1457. model: my.Field
  1458. });
  1459. // ## <a id="query">Query</a>
  1460. my.Query = Backbone.Model.extend({
  1461. constructor: function Query() {
  1462. Backbone.Model.prototype.constructor.apply(this, arguments);
  1463. },
  1464. defaults: function() {
  1465. return {
  1466. size: 100,
  1467. from: 0,
  1468. q: '',
  1469. facets: {},
  1470. filters: []
  1471. };
  1472. },
  1473. _filterTemplates: {
  1474. term: {
  1475. type: 'term',
  1476. // TODO do we need this attribute here?
  1477. field: '',
  1478. term: ''
  1479. },
  1480. range: {
  1481. type: 'range',
  1482. start: '',
  1483. stop: ''
  1484. },
  1485. geo_distance: {
  1486. type: 'geo_distance',
  1487. distance: 10,
  1488. unit: 'km',
  1489. point: {
  1490. lon: 0,
  1491. lat: 0
  1492. }
  1493. }
  1494. },
  1495. // ### addFilter
  1496. //
  1497. // Add a new filter (appended to the list of filters)
  1498. //
  1499. // @param filter an object specifying the filter - see _filterTemplates for examples. If only type is provided will generate a filter by cloning _filterTemplates
  1500. addFilter: function(filter) {
  1501. // crude deep copy
  1502. var ourfilter = JSON.parse(JSON.stringify(filter));
  1503. // not full specified so use template and over-write
  1504. // 3 as for 'type', 'field' and 'fieldType'
  1505. if (_.keys(filter).length <= 3) {
  1506. ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter);
  1507. }
  1508. var filters = this.get('filters');
  1509. filters.push(ourfilter);
  1510. this.trigger('change:filters:new-blank');
  1511. },
  1512. updateFilter: function(index, value) {
  1513. },
  1514. // ### removeFilter
  1515. //
  1516. // Remove a filter from filters at index filterIndex
  1517. removeFilter: function(filterIndex) {
  1518. var filters = this.get('filters');
  1519. filters.splice(filterIndex, 1);
  1520. this.set({filters: filters});
  1521. this.trigger('change');
  1522. },
  1523. // ### addFacet
  1524. //
  1525. // Add a Facet to this query
  1526. //
  1527. // See <http://www.elasticsearch.org/guide/reference/api/search/facets/>
  1528. addFacet: function(fieldId) {
  1529. var facets = this.get('facets');
  1530. // 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)
  1531. if (_.contains(_.keys(facets), fieldId)) {
  1532. return;
  1533. }
  1534. facets[fieldId] = {
  1535. terms: { field: fieldId }
  1536. };
  1537. this.set({facets: facets}, {silent: true});
  1538. this.trigger('facet:add', this);
  1539. },
  1540. addHistogramFacet: function(fieldId) {
  1541. var facets = this.get('facets');
  1542. facets[fieldId] = {
  1543. date_histogram: {
  1544. field: fieldId,
  1545. interval: 'day'
  1546. }
  1547. };
  1548. this.set({facets: facets}, {silent: true});
  1549. this.trigger('facet:add', this);
  1550. }
  1551. });
  1552. // ## <a id="facet">A Facet (Result)</a>
  1553. my.Facet = Backbone.Model.extend({
  1554. constructor: function Facet() {
  1555. Backbone.Model.prototype.constructor.apply(this, arguments);
  1556. },
  1557. defaults: function() {
  1558. return {
  1559. _type: 'terms',
  1560. total: 0,
  1561. other: 0,
  1562. missing: 0,
  1563. terms: []
  1564. };
  1565. }
  1566. });
  1567. // ## A Collection/List of Facets
  1568. my.FacetList = Backbone.Collection.extend({
  1569. constructor: function FacetList() {
  1570. Backbone.Collection.prototype.constructor.apply(this, arguments);
  1571. },
  1572. model: my.Facet
  1573. });
  1574. // ## Object State
  1575. //
  1576. // Convenience Backbone model for storing (configuration) state of objects like Views.
  1577. my.ObjectState = Backbone.Model.extend({
  1578. });
  1579. // ## Backbone.sync
  1580. //
  1581. // Override Backbone.sync to hand off to sync function in relevant backend
  1582. Backbone.sync = function(method, model, options) {
  1583. return model.backend.sync(method, model, options);
  1584. };
  1585. }(jQuery, this.recline.Model));
  1586. /*jshint multistr:true */
  1587. this.recline = this.recline || {};
  1588. this.recline.View = this.recline.View || {};
  1589. (function($, my) {
  1590. // ## Graph view for a Dataset using Flot graphing library.
  1591. //
  1592. // Initialization arguments (in a hash in first parameter):
  1593. //
  1594. // * model: recline.Model.Dataset
  1595. // * state: (optional) configuration hash of form:
  1596. //
  1597. // {
  1598. // group: {column name for x-axis},
  1599. // series: [{column name for series A}, {column name series B}, ... ],
  1600. // graphType: 'line'
  1601. // }
  1602. //
  1603. // NB: should *not* provide an el argument to the view but must let the view
  1604. // generate the element itself (you can then append view.el to the DOM.
  1605. my.Graph = Backbone.View.extend({
  1606. template: ' \
  1607. <div class="recline-graph"> \
  1608. <div class="panel graph" style="display: block;"> \
  1609. <div class="js-temp-notice alert alert-block"> \
  1610. <h3 class="alert-heading">Hey there!</h3> \
  1611. <p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \
  1612. <p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \
  1613. </div> \
  1614. </div> \
  1615. </div> \
  1616. ',
  1617. initialize: function(options) {
  1618. var self = this;
  1619. this.graphColors = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"];
  1620. this.el = $(this.el);
  1621. _.bindAll(this, 'render', 'redraw');
  1622. this.needToRedraw = false;
  1623. this.model.bind('change', this.render);
  1624. this.model.fields.bind('reset', this.render);
  1625. this.model.fields.bind('add', this.render);
  1626. this.model.records.bind('add', this.redraw);
  1627. this.model.records.bind('reset', this.redraw);
  1628. var stateData = _.extend({
  1629. group: null,
  1630. // so that at least one series chooser box shows up
  1631. series: [],
  1632. graphType: 'lines-and-points'
  1633. },
  1634. options.state
  1635. );
  1636. this.state = new recline.Model.ObjectState(stateData);
  1637. this.editor = new my.GraphControls({
  1638. model: this.model,
  1639. state: this.state.toJSON()
  1640. });
  1641. this.editor.state.bind('change', function() {
  1642. self.state.set(self.editor.state.toJSON());
  1643. self.redraw();
  1644. });
  1645. this.elSidebar = this.editor.el;
  1646. },
  1647. render: function() {
  1648. var self = this;
  1649. var tmplData = this.model.toTemplateJSON();
  1650. var htmls = Mustache.render(this.template, tmplData);
  1651. $(this.el).html(htmls);
  1652. this.$graph = this.el.find('.panel.graph');
  1653. return this;
  1654. },
  1655. redraw: function() {
  1656. // There appear to be issues generating a Flot graph if either:
  1657. // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with
  1658. //
  1659. // Uncaught Invalid dimensions for plot, width = 0, height = 0
  1660. // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
  1661. var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
  1662. if ((!areWeVisible || this.model.records.length === 0)) {
  1663. this.needToRedraw = true;
  1664. return;
  1665. }
  1666. // check we have something to plot
  1667. if (this.state.get('group') && this.state.get('series')) {
  1668. // faff around with width because flot draws axes *outside* of the element width which means graph can get push down as it hits element next to it
  1669. this.$graph.width(this.el.width() - 20);
  1670. var series = this.createSeries();
  1671. var options = this.getGraphOptions(this.state.attributes.graphType);
  1672. this.plot = Flotr.draw(this.$graph.get(0), series, options);
  1673. }
  1674. },
  1675. show: function() {
  1676. // because we cannot redraw when hidden we may need to when becoming visible
  1677. if (this.needToRedraw) {
  1678. this.redraw();
  1679. }
  1680. },
  1681. // ### getGraphOptions
  1682. //
  1683. // Get options for Flot Graph
  1684. //
  1685. // needs to be function as can depend on state
  1686. //
  1687. // @param typeId graphType id (lines, lines-and-points etc)
  1688. getGraphOptions: function(typeId) {
  1689. var self = this;
  1690. var tickFormatter = function (x) {
  1691. return getFormattedX(x);
  1692. };
  1693. var trackFormatter = function (obj) {
  1694. var x = obj.x;
  1695. var y = obj.y;
  1696. // it's horizontal so we have to flip
  1697. if (self.state.attributes.graphType === 'bars') {
  1698. var _tmp = x;
  1699. x = y;
  1700. y = _tmp;
  1701. }
  1702. x = getFormattedX(x);
  1703. var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
  1704. group: self.state.attributes.group,
  1705. x: x,
  1706. series: obj.series.label,
  1707. y: y
  1708. });
  1709. return content;
  1710. };
  1711. var getFormattedX = function (x) {
  1712. var xfield = self.model.fields.get(self.state.attributes.group);
  1713. // time series
  1714. var isDateTime = xfield.get('type') === 'date';
  1715. if (self.model.records.models[parseInt(x)]) {
  1716. x = self.model.records.models[parseInt(x)].get(self.state.attributes.group);
  1717. if (isDateTime) {
  1718. x = new Date(x).toLocaleDateString();
  1719. }
  1720. } else if (isDateTime) {
  1721. x = new Date(parseInt(x)).toLocaleDateString();
  1722. }
  1723. return x;
  1724. }
  1725. var xaxis = {};
  1726. xaxis.tickFormatter = tickFormatter;
  1727. var yaxis = {};
  1728. yaxis.autoscale = true;
  1729. yaxis.autoscaleMargin = 0.02;
  1730. var mouse = {};
  1731. mouse.track = true;
  1732. mouse.relative = true;
  1733. mouse.trackFormatter = trackFormatter;
  1734. var legend = {};
  1735. legend.position = 'ne';
  1736. // mouse.lineColor is set in createSeries
  1737. var optionsPerGraphType = {
  1738. lines: {
  1739. legend: legend,
  1740. colors: this.graphColors,
  1741. lines: { show: true },
  1742. xaxis: xaxis,
  1743. yaxis: yaxis,
  1744. mouse: mouse
  1745. },
  1746. points: {
  1747. legend: legend,
  1748. colors: this.graphColors,
  1749. points: { show: true, hitRadius: 5 },
  1750. xaxis: xaxis,
  1751. yaxis: yaxis,
  1752. mouse: mouse,
  1753. grid: { hoverable: true, clickable: true }
  1754. },
  1755. 'lines-and-points': {
  1756. legend: legend,
  1757. colors: this.graphColors,
  1758. points: { show: true, hitRadius: 5 },
  1759. lines: { show: true },
  1760. xaxis: xaxis,
  1761. yaxis: yaxis,
  1762. mouse: mouse,
  1763. grid: { hoverable: true, clickable: true }
  1764. },
  1765. bars: {
  1766. legend: legend,
  1767. colors: this.graphColors,
  1768. lines: { show: false },
  1769. xaxis: yaxis,
  1770. yaxis: xaxis,
  1771. mouse: {
  1772. track: true,
  1773. relative: true,
  1774. trackFormatter: trackFormatter,
  1775. fillColor: '#FFFFFF',
  1776. fillOpacity: 0.3,
  1777. position: 'e'
  1778. },
  1779. bars: {
  1780. show: true,
  1781. horizontal: true,
  1782. shadowSize: 0,
  1783. barWidth: 0.8
  1784. },
  1785. },
  1786. columns: {
  1787. legend: legend,
  1788. colors: this.graphColors,
  1789. lines: { show: false },
  1790. xaxis: xaxis,
  1791. yaxis: yaxis,
  1792. mouse: {
  1793. track: true,
  1794. relative: true,
  1795. trackFormatter: trackFormatter,
  1796. fillColor: '#FFFFFF',
  1797. fillOpacity: 0.3,
  1798. position: 'n'
  1799. },
  1800. bars: {
  1801. show: true,
  1802. horizontal: false,
  1803. shadowSize: 0,
  1804. barWidth: 0.8
  1805. },
  1806. },
  1807. grid: { hoverable: true, clickable: true },
  1808. };
  1809. return optionsPerGraphType[typeId];
  1810. },
  1811. createSeries: function() {
  1812. var self = this;
  1813. var series = [];
  1814. _.each(this.state.attributes.series, function(field) {
  1815. var points = [];
  1816. _.each(self.model.records.models, function(doc, index) {
  1817. var xfield = self.model.fields.get(self.state.attributes.group);
  1818. var x = doc.getFieldValue(xfield);
  1819. // time series
  1820. var isDateTime = xfield.get('type') === 'date';
  1821. if (isDateTime) {
  1822. // datetime
  1823. if (self.state.attributes.graphType != 'bars' && self.state.attributes.graphType != 'columns') {
  1824. // not bar or column
  1825. x = new Date(x).getTime();
  1826. } else {
  1827. // bar or column
  1828. x = index;
  1829. }
  1830. } else if (typeof x === 'string') {
  1831. // string
  1832. x = parseFloat(x);
  1833. if (isNaN(x)) {
  1834. x = index;
  1835. }
  1836. }
  1837. var yfield = self.model.fields.get(field);
  1838. var y = doc.getFieldValue(yfield);
  1839. // horizontal bar chart
  1840. if (self.state.attributes.graphType == 'bars') {
  1841. points.push([y, x]);
  1842. } else {
  1843. points.push([x, y]);
  1844. }
  1845. });
  1846. series.push({data: points, label: field, mouse:{lineColor: self.graphColors[series.length]}});
  1847. });
  1848. return series;
  1849. }
  1850. });
  1851. my.GraphControls = Backbone.View.extend({
  1852. className: "editor",
  1853. template: ' \
  1854. <div class="editor"> \
  1855. <form class="form-stacked"> \
  1856. <div class="clearfix"> \
  1857. <label>Graph Type</label> \
  1858. <div class="input editor-type"> \
  1859. <select> \
  1860. <option value="lines-and-points">Lines and Points</option> \
  1861. <option value="lines">Lines</option> \
  1862. <option value="points">Points</option> \
  1863. <option value="bars">Bars</option> \
  1864. <option value="columns">Columns</option> \
  1865. </select> \
  1866. </div> \
  1867. <label>Group Column (x-axis)</label> \
  1868. <div class="input editor-group"> \
  1869. <select> \
  1870. <option value="">Please choose ...</option> \
  1871. {{#fields}} \
  1872. <option value="{{id}}">{{label}}</option> \
  1873. {{/fields}} \
  1874. </select> \
  1875. </div> \
  1876. <div class="editor-series-group"> \
  1877. </div> \
  1878. </div> \
  1879. <div class="editor-buttons"> \
  1880. <button class="btn editor-add">Add Series</button> \
  1881. </div> \
  1882. <div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
  1883. <button class="editor-save">Save</button> \
  1884. <input type="hidden" class="editor-id" value="chart-1" /> \
  1885. </div> \
  1886. </form> \
  1887. </div> \
  1888. ',
  1889. templateSeriesEditor: ' \
  1890. <div class="editor-series js-series-{{seriesIndex}}"> \
  1891. <label>Series <span>{{seriesName}} (y-axis)</span> \
  1892. [<a href="#remove" class="action-remove-series">Remove</a>] \
  1893. </label> \
  1894. <div class="input"> \
  1895. <select> \
  1896. {{#fields}} \
  1897. <option value="{{id}}">{{label}}</option> \
  1898. {{/fields}} \
  1899. </select> \
  1900. </div> \
  1901. </div> \
  1902. ',
  1903. events: {
  1904. 'change form select': 'onEditorSubmit',
  1905. 'click .editor-add': '_onAddSeries',
  1906. 'click .action-remove-series': 'removeSeries'
  1907. },
  1908. initialize: function(options) {
  1909. var self = this;
  1910. this.el = $(this.el);
  1911. _.bindAll(this, 'render');
  1912. this.model.fields.bind('reset', this.render);
  1913. this.model.fields.bind('add', this.render);
  1914. this.state = new recline.Model.ObjectState(options.state);
  1915. this.render();
  1916. },
  1917. render: function() {
  1918. var self = this;
  1919. var tmplData = this.model.toTemplateJSON();
  1920. var htmls = Mustache.render(this.template, tmplData);
  1921. this.el.html(htmls);
  1922. // set up editor from state
  1923. if (this.state.get('graphType')) {
  1924. this._selectOption('.editor-type', this.state.get('graphType'));
  1925. }
  1926. if (this.state.get('group')) {
  1927. this._selectOption('.editor-group', this.state.get('group'));
  1928. }
  1929. // ensure at least one series box shows up
  1930. var tmpSeries = [""];
  1931. if (this.state.get('series').length > 0) {
  1932. tmpSeries = this.state.get('series');
  1933. }
  1934. _.each(tmpSeries, function(series, idx) {
  1935. self.addSeries(idx);
  1936. self._selectOption('.editor-series.js-series-' + idx, series);
  1937. });
  1938. return this;
  1939. },
  1940. // Private: Helper function to select an option from a select list
  1941. //
  1942. _selectOption: function(id,value){
  1943. var options = this.el.find(id + ' select > option');
  1944. if (options) {
  1945. options.each(function(opt){
  1946. if (this.value == value) {
  1947. $(this).attr('selected','selected');
  1948. return false;
  1949. }
  1950. });
  1951. }
  1952. },
  1953. onEditorSubmit: function(e) {
  1954. var select = this.el.find('.editor-group select');
  1955. var $editor = this;
  1956. var $series = this.el.find('.editor-series select');
  1957. var series = $series.map(function () {
  1958. return $(this).val();
  1959. });
  1960. var updatedState = {
  1961. series: $.makeArray(series),
  1962. group: this.el.find('.editor-group select').val(),
  1963. graphType: this.el.find('.editor-type select').val()
  1964. };
  1965. this.state.set(updatedState);
  1966. },
  1967. // Public: Adds a new empty series select box to the editor.
  1968. //
  1969. // @param [int] idx index of this series in the list of series
  1970. //
  1971. // Returns itself.
  1972. addSeries: function (idx) {
  1973. var data = _.extend({
  1974. seriesIndex: idx,
  1975. seriesName: String.fromCharCode(idx + 64 + 1),
  1976. }, this.model.toTemplateJSON());
  1977. var htmls = Mustache.render(this.templateSeriesEditor, data);
  1978. this.el.find('.editor-series-group').append(htmls);
  1979. return this;
  1980. },
  1981. _onAddSeries: function(e) {
  1982. e.preventDefault();
  1983. this.addSeries(this.state.get('series').length);
  1984. },
  1985. // Public: Removes a series list item from the editor.
  1986. //
  1987. // Also updates the labels of the remaining series elements.
  1988. removeSeries: function (e) {
  1989. e.preventDefault();
  1990. var $el = $(e.target);
  1991. $el.parent().parent().remove();
  1992. this.onEditorSubmit();
  1993. }
  1994. });
  1995. })(jQuery, recline.View);
  1996. /*jshint multistr:true */
  1997. this.recline = this.recline || {};
  1998. this.recline.View = this.recline.View || {};
  1999. (function($, my) {
  2000. // ## (Data) Grid Dataset View
  2001. //
  2002. // Provides a tabular view on a Dataset.
  2003. //
  2004. // Initialize it with a `recline.Model.Dataset`.
  2005. my.Grid = Backbone.View.extend({
  2006. tagName: "div",
  2007. className: "recline-grid-container",
  2008. initialize: function(modelEtc) {
  2009. var self = this;
  2010. this.el = $(this.el);
  2011. _.bindAll(this, 'render', 'onHorizontalScroll');
  2012. this.model.records.bind('add', this.render);
  2013. this.model.records.bind('reset', this.render);
  2014. this.model.records.bind('remove', this.render);
  2015. this.tempState = {};
  2016. var state = _.extend({
  2017. hiddenFields: []
  2018. }, modelEtc.state
  2019. );
  2020. this.state = new recline.Model.ObjectState(state);
  2021. },
  2022. events: {
  2023. // does not work here so done at end of render function
  2024. // 'scroll .recline-grid tbody': 'onHorizontalScroll'
  2025. },
  2026. // ======================================================
  2027. // Column and row menus
  2028. setColumnSort: function(order) {
  2029. var sort = [{}];
  2030. sort[0][this.tempState.currentColumn] = {order: order};
  2031. this.model.query({sort: sort});
  2032. },
  2033. hideColumn: function() {
  2034. var hiddenFields = this.state.get('hiddenFields');
  2035. hiddenFields.push(this.tempState.currentColumn);
  2036. this.state.set({hiddenFields: hiddenFields});
  2037. // change event not being triggered (because it is an array?) so trigger manually
  2038. this.state.trigger('change');
  2039. this.render();
  2040. },
  2041. showColumn: function(e) {
  2042. var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column'));
  2043. this.state.set({hiddenFields: hiddenFields});
  2044. this.render();
  2045. },
  2046. onHorizontalScroll: function(e) {
  2047. var currentScroll = $(e.target).scrollLeft();
  2048. this.el.find('.recline-grid thead tr').scrollLeft(currentScroll);
  2049. },
  2050. // ======================================================
  2051. // #### Templating
  2052. template: ' \
  2053. <div class="table-container"> \
  2054. <table class="recline-grid table-striped table-condensed" cellspacing="0"> \
  2055. <thead class="fixed-header"> \
  2056. <tr> \
  2057. {{#fields}} \
  2058. <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;" title="{{label}}"> \
  2059. <span class="column-header-name">{{label}}</span> \
  2060. </th> \
  2061. {{/fields}} \
  2062. <th class="last-header" style="width: {{lastHeaderWidth}}px; max-width: {{lastHeaderWidth}}px; min-width: {{lastHeaderWidth}}px; padding: 0; margin: 0;"></th> \
  2063. </tr> \
  2064. </thead> \
  2065. <tbody class="scroll-content"></tbody> \
  2066. </table> \
  2067. </div> \
  2068. ',
  2069. toTemplateJSON: function() {
  2070. var self = this;
  2071. var modelData = this.model.toJSON();
  2072. modelData.notEmpty = ( this.fields.length > 0 );
  2073. // TODO: move this sort of thing into a toTemplateJSON method on Dataset?
  2074. modelData.fields = _.map(this.fields, function(field) {
  2075. return field.toJSON();
  2076. });
  2077. // last header width = scroll bar - border (2px) */
  2078. modelData.lastHeaderWidth = this.scrollbarDimensions.width - 2;
  2079. return modelData;
  2080. },
  2081. render: function() {
  2082. var self = this;
  2083. this.fields = this.model.fields.filter(function(field) {
  2084. return _.indexOf(self.state.get('hiddenFields'), field.id) == -1;
  2085. });
  2086. this.scrollbarDimensions = this.scrollbarDimensions || this._scrollbarSize(); // skip measurement if already have dimensions
  2087. var numFields = this.fields.length;
  2088. // compute field widths (-20 for first menu col + 10px for padding on each col and finally 16px for the scrollbar)
  2089. var fullWidth = self.el.width() - 20 - 10 * numFields - this.scrollbarDimensions.width;
  2090. var width = parseInt(Math.max(50, fullWidth / numFields));
  2091. // if columns extend outside viewport then remainder is 0
  2092. var remainder = Math.max(fullWidth - numFields * width,0);
  2093. _.each(this.fields, function(field, idx) {
  2094. // add the remainder to the first field width so we make up full col
  2095. if (idx == 0) {
  2096. field.set({width: width+remainder});
  2097. } else {
  2098. field.set({width: width});
  2099. }
  2100. });
  2101. var htmls = Mustache.render(this.template, this.toTemplateJSON());
  2102. this.el.html(htmls);
  2103. this.model.records.forEach(function(doc) {
  2104. var tr = $('<tr />');
  2105. self.el.find('tbody').append(tr);
  2106. var newView = new my.GridRow({
  2107. model: doc,
  2108. el: tr,
  2109. fields: self.fields
  2110. });
  2111. newView.render();
  2112. });
  2113. // hide extra header col if no scrollbar to avoid unsightly overhang
  2114. var $tbody = this.el.find('tbody')[0];
  2115. if ($tbody.scrollHeight <= $tbody.offsetHeight) {
  2116. this.el.find('th.last-header').hide();
  2117. }
  2118. this.el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
  2119. this.el.find('.recline-grid tbody').scroll(this.onHorizontalScroll);
  2120. return this;
  2121. },
  2122. // ### _scrollbarSize
  2123. //
  2124. // Measure width of a vertical scrollbar and height of a horizontal scrollbar.
  2125. //
  2126. // @return: { width: pixelWidth, height: pixelHeight }
  2127. _scrollbarSize: function() {
  2128. var $c = $("<div style='position:absolute; top:-10000px; left:-10000px; width:100px; height:100px; overflow:scroll;'></div>").appendTo("body");
  2129. var dim = { width: $c.width() - $c[0].clientWidth + 1, height: $c.height() - $c[0].clientHeight };
  2130. $c.remove();
  2131. return dim;
  2132. }
  2133. });
  2134. // ## GridRow View for rendering an individual record.
  2135. //
  2136. // Since we want this to update in place it is up to creator to provider the element to attach to.
  2137. //
  2138. // In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the Grid.
  2139. //
  2140. // Example:
  2141. //
  2142. // <pre>
  2143. // var row = new GridRow({
  2144. // model: dataset-record,
  2145. // el: dom-element,
  2146. // fields: mydatasets.fields // a FieldList object
  2147. // });
  2148. // </pre>
  2149. my.GridRow = Backbone.View.extend({
  2150. initialize: function(initData) {
  2151. _.bindAll(this, 'render');
  2152. this._fields = initData.fields;
  2153. this.el = $(this.el);
  2154. this.model.bind('change', this.render);
  2155. },
  2156. template: ' \
  2157. {{#cells}} \
  2158. <td data-field="{{field}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;"> \
  2159. <div class="data-table-cell-content"> \
  2160. <a href="javascript:{}" class="data-table-cell-edit" title="Edit this cell">&nbsp;</a> \
  2161. <div class="data-table-cell-value">{{{value}}}</div> \
  2162. </div> \
  2163. </td> \
  2164. {{/cells}} \
  2165. ',
  2166. events: {
  2167. 'click .data-table-cell-edit': 'onEditClick',
  2168. 'click .data-table-cell-editor .okButton': 'onEditorOK',
  2169. 'click .data-table-cell-editor .cancelButton': 'onEditorCancel'
  2170. },
  2171. toTemplateJSON: function() {
  2172. var self = this;
  2173. var doc = this.model;
  2174. var cellData = this._fields.map(function(field) {
  2175. return {
  2176. field: field.id,
  2177. width: field.get('width'),
  2178. value: doc.getFieldValue(field)
  2179. };
  2180. });
  2181. return { id: this.id, cells: cellData };
  2182. },
  2183. render: function() {
  2184. this.el.attr('data-id', this.model.id);
  2185. var html = Mustache.render(this.template, this.toTemplateJSON());
  2186. $(this.el).html(html);
  2187. return this;
  2188. },
  2189. // ===================
  2190. // Cell Editor methods
  2191. cellEditorTemplate: ' \
  2192. <div class="menu-container data-table-cell-editor"> \
  2193. <textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
  2194. <div id="data-table-cell-editor-actions"> \
  2195. <div class="data-table-cell-editor-action"> \
  2196. <button class="okButton btn primary">Update</button> \
  2197. <button class="cancelButton btn danger">Cancel</button> \
  2198. </div> \
  2199. </div> \
  2200. </div> \
  2201. ',
  2202. onEditClick: function(e) {
  2203. var editing = this.el.find('.data-table-cell-editor-editor');
  2204. if (editing.length > 0) {
  2205. editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden");
  2206. }
  2207. $(e.target).addClass("hidden");
  2208. var cell = $(e.target).siblings('.data-table-cell-value');
  2209. cell.data("previousContents", cell.text());
  2210. var templated = Mustache.render(this.cellEditorTemplate, {value: cell.text()});
  2211. cell.html(templated);
  2212. },
  2213. onEditorOK: function(e) {
  2214. var self = this;
  2215. var cell = $(e.target);
  2216. var rowId = cell.parents('tr').attr('data-id');
  2217. var field = cell.parents('td').attr('data-field');
  2218. var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val();
  2219. var newData = {};
  2220. newData[field] = newValue;
  2221. this.model.set(newData);
  2222. this.trigger('recline:flash', {message: "Updating row...", loader: true});
  2223. this.model.save().then(function(response) {
  2224. this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'});
  2225. })
  2226. .fail(function() {
  2227. this.trigger('recline:flash', {
  2228. message: 'Error saving row',
  2229. category: 'error',
  2230. persist: true
  2231. });
  2232. });
  2233. },
  2234. onEditorCancel: function(e) {
  2235. var cell = $(e.target).parents('.data-table-cell-value');
  2236. cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden");
  2237. }
  2238. });
  2239. })(jQuery, recline.View);
  2240. /*jshint multistr:true */
  2241. this.recline = this.recline || {};
  2242. this.recline.View = this.recline.View || {};
  2243. (function($, my) {
  2244. // ## Map view for a Dataset using Leaflet mapping library.
  2245. //
  2246. // This view allows to plot gereferenced records on a map. The location
  2247. // information can be provided either via a field with
  2248. // [GeoJSON](http://geojson.org) objects or two fields with latitude and
  2249. // longitude coordinates.
  2250. //
  2251. // Initialization arguments are as standard for Dataset Views. State object may
  2252. // have the following (optional) configuration options:
  2253. //
  2254. // <pre>
  2255. // {
  2256. // // geomField if specified will be used in preference to lat/lon
  2257. // geomField: {id of field containing geometry in the dataset}
  2258. // lonField: {id of field containing longitude in the dataset}
  2259. // latField: {id of field containing latitude in the dataset}
  2260. // }
  2261. // </pre>
  2262. my.Map = Backbone.View.extend({
  2263. template: ' \
  2264. <div class="recline-map"> \
  2265. <div class="panel map"></div> \
  2266. </div> \
  2267. ',
  2268. // These are the default (case-insensitive) names of field that are used if found.
  2269. // If not found, the user will need to define the fields via the editor.
  2270. latitudeFieldNames: ['lat','latitude'],
  2271. longitudeFieldNames: ['lon','longitude'],
  2272. geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location', 'geo', 'lonlat'],
  2273. initialize: function(options) {
  2274. var self = this;
  2275. this.el = $(this.el);
  2276. this.visible = true;
  2277. this.mapReady = false;
  2278. var stateData = _.extend({
  2279. geomField: null,
  2280. lonField: null,
  2281. latField: null,
  2282. autoZoom: true
  2283. },
  2284. options.state
  2285. );
  2286. this.state = new recline.Model.ObjectState(stateData);
  2287. // Listen to changes in the fields
  2288. this.model.fields.bind('change', function() {
  2289. self._setupGeometryField()
  2290. self.render()
  2291. });
  2292. // Listen to changes in the records
  2293. this.model.records.bind('add', function(doc){self.redraw('add',doc)});
  2294. this.model.records.bind('change', function(doc){
  2295. self.redraw('remove',doc);
  2296. self.redraw('add',doc);
  2297. });
  2298. this.model.records.bind('remove', function(doc){self.redraw('remove',doc)});
  2299. this.model.records.bind('reset', function(){self.redraw('reset')});
  2300. this.menu = new my.MapMenu({
  2301. model: this.model,
  2302. state: this.state.toJSON()
  2303. });
  2304. this.menu.state.bind('change', function() {
  2305. self.state.set(self.menu.state.toJSON());
  2306. self.redraw();
  2307. });
  2308. this.elSidebar = this.menu.el;
  2309. },
  2310. // ### Public: Adds the necessary elements to the page.
  2311. //
  2312. // Also sets up the editor fields and the map if necessary.
  2313. render: function() {
  2314. var self = this;
  2315. htmls = Mustache.render(this.template, this.model.toTemplateJSON());
  2316. $(this.el).html(htmls);
  2317. this.$map = this.el.find('.panel.map');
  2318. this.redraw();
  2319. return this;
  2320. },
  2321. // ### Public: Redraws the features on the map according to the action provided
  2322. //
  2323. // Actions can be:
  2324. //
  2325. // * reset: Clear all features
  2326. // * add: Add one or n features (records)
  2327. // * remove: Remove one or n features (records)
  2328. // * refresh: Clear existing features and add all current records
  2329. redraw: function(action, doc){
  2330. var self = this;
  2331. action = action || 'refresh';
  2332. // try to set things up if not already
  2333. if (!self._geomReady()){
  2334. self._setupGeometryField();
  2335. }
  2336. if (!self.mapReady){
  2337. self._setupMap();
  2338. }
  2339. if (this._geomReady() && this.mapReady){
  2340. if (action == 'reset' || action == 'refresh'){
  2341. this.features.clearLayers();
  2342. this._add(this.model.records.models);
  2343. } else if (action == 'add' && doc){
  2344. this._add(doc);
  2345. } else if (action == 'remove' && doc){
  2346. this._remove(doc);
  2347. }
  2348. if (this.state.get('autoZoom')){
  2349. if (this.visible){
  2350. this._zoomToFeatures();
  2351. } else {
  2352. this._zoomPending = true;
  2353. }
  2354. }
  2355. }
  2356. },
  2357. show: function() {
  2358. // If the div was hidden, Leaflet needs to recalculate some sizes
  2359. // to display properly
  2360. if (this.map){
  2361. this.map.invalidateSize();
  2362. if (this._zoomPending && this.state.get('autoZoom')) {
  2363. this._zoomToFeatures();
  2364. this._zoomPending = false;
  2365. }
  2366. }
  2367. this.visible = true;
  2368. },
  2369. hide: function() {
  2370. this.visible = false;
  2371. },
  2372. _geomReady: function() {
  2373. return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
  2374. },
  2375. // Private: Add one or n features to the map
  2376. //
  2377. // For each record passed, a GeoJSON geometry will be extracted and added
  2378. // to the features layer. If an exception is thrown, the process will be
  2379. // stopped and an error notification shown.
  2380. //
  2381. // Each feature will have a popup associated with all the record fields.
  2382. //
  2383. _add: function(docs){
  2384. var self = this;
  2385. if (!(docs instanceof Array)) docs = [docs];
  2386. var count = 0;
  2387. var wrongSoFar = 0;
  2388. _.every(docs,function(doc){
  2389. count += 1;
  2390. var feature = self._getGeometryFromRecord(doc);
  2391. if (typeof feature === 'undefined' || feature === null){
  2392. // Empty field
  2393. return true;
  2394. } else if (feature instanceof Object){
  2395. // Build popup contents
  2396. // TODO: mustache?
  2397. html = ''
  2398. for (key in doc.attributes){
  2399. if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
  2400. html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
  2401. }
  2402. }
  2403. feature.properties = {popupContent: html};
  2404. // Add a reference to the model id, which will allow us to
  2405. // link this Leaflet layer to a Recline doc
  2406. feature.properties.cid = doc.cid;
  2407. try {
  2408. self.features.addGeoJSON(feature);
  2409. } catch (except) {
  2410. wrongSoFar += 1;
  2411. var msg = 'Wrong geometry value';
  2412. if (except.message) msg += ' (' + except.message + ')';
  2413. if (wrongSoFar <= 10) {
  2414. self.trigger('recline:flash', {message: msg, category:'error'});
  2415. }
  2416. }
  2417. } else {
  2418. wrongSoFar += 1
  2419. if (wrongSoFar <= 10) {
  2420. self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'});
  2421. }
  2422. }
  2423. return true;
  2424. });
  2425. },
  2426. // Private: Remove one or n features to the map
  2427. //
  2428. _remove: function(docs){
  2429. var self = this;
  2430. if (!(docs instanceof Array)) docs = [docs];
  2431. _.each(docs,function(doc){
  2432. for (key in self.features._layers){
  2433. if (self.features._layers[key].cid == doc.cid){
  2434. self.features.removeLayer(self.features._layers[key]);
  2435. }
  2436. }
  2437. });
  2438. },
  2439. // Private: Return a GeoJSON geomtry extracted from the record fields
  2440. //
  2441. _getGeometryFromRecord: function(doc){
  2442. if (this.state.get('geomField')){
  2443. var value = doc.get(this.state.get('geomField'));
  2444. if (typeof(value) === 'string'){
  2445. // We *may* have a GeoJSON string representation
  2446. try {
  2447. value = $.parseJSON(value);
  2448. } catch(e) {}
  2449. }
  2450. if (typeof(value) === 'string') {
  2451. value = value.replace('(', '').replace(')', '');
  2452. var parts = value.split(',');
  2453. var lat = parseFloat(parts[0]);
  2454. var lon = parseFloat(parts[1]);
  2455. if (!isNaN(lon) && !isNaN(parseFloat(lat))) {
  2456. return {
  2457. "type": "Point",
  2458. "coordinates": [lon, lat]
  2459. };
  2460. } else {
  2461. return null;
  2462. }
  2463. } else if (value && value.slice) {
  2464. // [ lon, lat ]
  2465. return {
  2466. "type": "Point",
  2467. "coordinates": [value[0], value[1]]
  2468. };
  2469. } else if (value && value.lat) {
  2470. // of form { lat: ..., lon: ...}
  2471. return {
  2472. "type": "Point",
  2473. "coordinates": [value.lon || value.lng, value.lat]
  2474. };
  2475. }
  2476. // We o/w assume that contents of the field are a valid GeoJSON object
  2477. return value;
  2478. } else if (this.state.get('lonField') && this.state.get('latField')){
  2479. // We'll create a GeoJSON like point object from the two lat/lon fields
  2480. var lon = doc.get(this.state.get('lonField'));
  2481. var lat = doc.get(this.state.get('latField'));
  2482. if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
  2483. return {
  2484. type: 'Point',
  2485. coordinates: [lon,lat]
  2486. };
  2487. }
  2488. }
  2489. return null;
  2490. },
  2491. // Private: Check if there is a field with GeoJSON geometries or alternatively,
  2492. // two fields with lat/lon values.
  2493. //
  2494. // If not found, the user can define them via the UI form.
  2495. _setupGeometryField: function(){
  2496. // should not overwrite if we have already set this (e.g. explicitly via state)
  2497. if (!this._geomReady()) {
  2498. this.state.set({
  2499. geomField: this._checkField(this.geometryFieldNames),
  2500. latField: this._checkField(this.latitudeFieldNames),
  2501. lonField: this._checkField(this.longitudeFieldNames)
  2502. });
  2503. this.menu.state.set(this.state.toJSON());
  2504. }
  2505. },
  2506. // Private: Check if a field in the current model exists in the provided
  2507. // list of names.
  2508. //
  2509. //
  2510. _checkField: function(fieldNames){
  2511. var field;
  2512. var modelFieldNames = this.model.fields.pluck('id');
  2513. for (var i = 0; i < fieldNames.length; i++){
  2514. for (var j = 0; j < modelFieldNames.length; j++){
  2515. if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase())
  2516. return modelFieldNames[j];
  2517. }
  2518. }
  2519. return null;
  2520. },
  2521. // Private: Zoom to map to current features extent if any, or to the full
  2522. // extent if none.
  2523. //
  2524. _zoomToFeatures: function(){
  2525. var bounds = this.features.getBounds();
  2526. if (bounds){
  2527. this.map.fitBounds(bounds);
  2528. } else {
  2529. this.map.setView(new L.LatLng(0, 0), 2);
  2530. }
  2531. },
  2532. // Private: Sets up the Leaflet map control and the features layer.
  2533. //
  2534. // The map uses a base layer from [MapQuest](http://www.mapquest.com) based
  2535. // on [OpenStreetMap](http://openstreetmap.org).
  2536. //
  2537. _setupMap: function(){
  2538. this.map = new L.Map(this.$map.get(0));
  2539. var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
  2540. var osmAttribution = 'Map data &copy; 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">';
  2541. var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
  2542. this.map.addLayer(bg);
  2543. this.features = new L.GeoJSON();
  2544. this.features.on('featureparse', function (e) {
  2545. if (e.properties && e.properties.popupContent){
  2546. e.layer.bindPopup(e.properties.popupContent);
  2547. }
  2548. if (e.properties && e.properties.cid){
  2549. e.layer.cid = e.properties.cid;
  2550. }
  2551. });
  2552. // This will be available in the next Leaflet stable release.
  2553. // In the meantime we add it manually to our layer.
  2554. this.features.getBounds = function(){
  2555. var bounds = new L.LatLngBounds();
  2556. this._iterateLayers(function (layer) {
  2557. if (layer instanceof L.Marker){
  2558. bounds.extend(layer.getLatLng());
  2559. } else {
  2560. if (layer.getBounds){
  2561. bounds.extend(layer.getBounds().getNorthEast());
  2562. bounds.extend(layer.getBounds().getSouthWest());
  2563. }
  2564. }
  2565. }, this);
  2566. return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
  2567. }
  2568. this.map.addLayer(this.features);
  2569. this.map.setView(new L.LatLng(0, 0), 2);
  2570. this.mapReady = true;
  2571. },
  2572. // Private: Helper function to select an option from a select list
  2573. //
  2574. _selectOption: function(id,value){
  2575. var options = $('.' + id + ' > select > option');
  2576. if (options){
  2577. options.each(function(opt){
  2578. if (this.value == value) {
  2579. $(this).attr('selected','selected');
  2580. return false;
  2581. }
  2582. });
  2583. }
  2584. }
  2585. });
  2586. my.MapMenu = Backbone.View.extend({
  2587. className: 'editor',
  2588. template: ' \
  2589. <form class="form-stacked"> \
  2590. <div class="clearfix"> \
  2591. <div class="editor-field-type"> \
  2592. <label class="radio"> \
  2593. <input type="radio" id="editor-field-type-latlon" name="editor-field-type" value="latlon" checked="checked"/> \
  2594. Latitude / Longitude fields</label> \
  2595. <label class="radio"> \
  2596. <input type="radio" id="editor-field-type-geom" name="editor-field-type" value="geom" /> \
  2597. GeoJSON field</label> \
  2598. </div> \
  2599. <div class="editor-field-type-latlon"> \
  2600. <label>Latitude field</label> \
  2601. <div class="input editor-lat-field"> \
  2602. <select> \
  2603. <option value=""></option> \
  2604. {{#fields}} \
  2605. <option value="{{id}}">{{label}}</option> \
  2606. {{/fields}} \
  2607. </select> \
  2608. </div> \
  2609. <label>Longitude field</label> \
  2610. <div class="input editor-lon-field"> \
  2611. <select> \
  2612. <option value=""></option> \
  2613. {{#fields}} \
  2614. <option value="{{id}}">{{label}}</option> \
  2615. {{/fields}} \
  2616. </select> \
  2617. </div> \
  2618. </div> \
  2619. <div class="editor-field-type-geom" style="display:none"> \
  2620. <label>Geometry field (GeoJSON)</label> \
  2621. <div class="input editor-geom-field"> \
  2622. <select> \
  2623. <option value=""></option> \
  2624. {{#fields}} \
  2625. <option value="{{id}}">{{label}}</option> \
  2626. {{/fields}} \
  2627. </select> \
  2628. </div> \
  2629. </div> \
  2630. </div> \
  2631. <div class="editor-buttons"> \
  2632. <button class="btn editor-update-map">Update</button> \
  2633. </div> \
  2634. <div class="editor-options" > \
  2635. <label class="checkbox"> \
  2636. <input type="checkbox" id="editor-auto-zoom" checked="checked" /> \
  2637. Auto zoom to features</label> \
  2638. </div> \
  2639. <input type="hidden" class="editor-id" value="map-1" /> \
  2640. </div> \
  2641. </form> \
  2642. ',
  2643. // Define here events for UI elements
  2644. events: {
  2645. 'click .editor-update-map': 'onEditorSubmit',
  2646. 'change .editor-field-type': 'onFieldTypeChange',
  2647. 'click #editor-auto-zoom': 'onAutoZoomChange'
  2648. },
  2649. initialize: function(options) {
  2650. var self = this;
  2651. this.el = $(this.el);
  2652. _.bindAll(this, 'render');
  2653. this.model.fields.bind('change', this.render);
  2654. this.state = new recline.Model.ObjectState(options.state);
  2655. this.state.bind('change', this.render);
  2656. this.render();
  2657. },
  2658. // ### Public: Adds the necessary elements to the page.
  2659. //
  2660. // Also sets up the editor fields and the map if necessary.
  2661. render: function() {
  2662. var self = this;
  2663. htmls = Mustache.render(this.template, this.model.toTemplateJSON());
  2664. $(this.el).html(htmls);
  2665. if (this._geomReady() && this.model.fields.length){
  2666. if (this.state.get('geomField')){
  2667. this._selectOption('editor-geom-field',this.state.get('geomField'));
  2668. this.el.find('#editor-field-type-geom').attr('checked','checked').change();
  2669. } else{
  2670. this._selectOption('editor-lon-field',this.state.get('lonField'));
  2671. this._selectOption('editor-lat-field',this.state.get('latField'));
  2672. this.el.find('#editor-field-type-latlon').attr('checked','checked').change();
  2673. }
  2674. }
  2675. if (this.state.get('autoZoom')) {
  2676. this.el.find('#editor-auto-zoom').attr('checked', 'checked');
  2677. }
  2678. else {
  2679. this.el.find('#editor-auto-zoom').removeAttr('checked');
  2680. }
  2681. return this;
  2682. },
  2683. _geomReady: function() {
  2684. return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
  2685. },
  2686. // ## UI Event handlers
  2687. //
  2688. // Public: Update map with user options
  2689. //
  2690. // Right now the only configurable option is what field(s) contains the
  2691. // location information.
  2692. //
  2693. onEditorSubmit: function(e){
  2694. e.preventDefault();
  2695. if (this.el.find('#editor-field-type-geom').attr('checked')){
  2696. this.state.set({
  2697. geomField: this.el.find('.editor-geom-field > select > option:selected').val(),
  2698. lonField: null,
  2699. latField: null
  2700. });
  2701. } else {
  2702. this.state.set({
  2703. geomField: null,
  2704. lonField: this.el.find('.editor-lon-field > select > option:selected').val(),
  2705. latField: this.el.find('.editor-lat-field > select > option:selected').val()
  2706. });
  2707. }
  2708. return false;
  2709. },
  2710. // Public: Shows the relevant select lists depending on the location field
  2711. // type selected.
  2712. //
  2713. onFieldTypeChange: function(e){
  2714. if (e.target.value == 'geom'){
  2715. this.el.find('.editor-field-type-geom').show();
  2716. this.el.find('.editor-field-type-latlon').hide();
  2717. } else {
  2718. this.el.find('.editor-field-type-geom').hide();
  2719. this.el.find('.editor-field-type-latlon').show();
  2720. }
  2721. },
  2722. onAutoZoomChange: function(e){
  2723. this.state.set({autoZoom: !this.state.get('autoZoom')});
  2724. },
  2725. // Private: Helper function to select an option from a select list
  2726. //
  2727. _selectOption: function(id,value){
  2728. var options = this.el.find('.' + id + ' > select > option');
  2729. if (options){
  2730. options.each(function(opt){
  2731. if (this.value == value) {
  2732. $(this).attr('selected','selected');
  2733. return false;
  2734. }
  2735. });
  2736. }
  2737. }
  2738. });
  2739. })(jQuery, recline.View);
  2740. /*jshint multistr:true */
  2741. // Standard JS module setup
  2742. this.recline = this.recline || {};
  2743. this.recline.View = this.recline.View || {};
  2744. (function($, my) {
  2745. // ## MultiView
  2746. //
  2747. // Manage multiple views together along with query editor etc. Usage:
  2748. //
  2749. // <pre>
  2750. // var myExplorer = new model.recline.MultiView({
  2751. // model: {{recline.Model.Dataset instance}}
  2752. // el: {{an existing dom element}}
  2753. // views: {{dataset views}}
  2754. // state: {{state configuration -- see below}}
  2755. // });
  2756. // </pre>
  2757. //
  2758. // ### Parameters
  2759. //
  2760. // **model**: (required) recline.model.Dataset instance.
  2761. //
  2762. // **el**: (required) DOM element to bind to. NB: the element already
  2763. // being in the DOM is important for rendering of some subviews (e.g.
  2764. // Graph).
  2765. //
  2766. // **views**: (optional) the dataset views (Grid, Graph etc) for
  2767. // MultiView to show. This is an array of view hashes. If not provided
  2768. // initialize with (recline.View.)Grid, Graph, and Map views (with obvious id
  2769. // and labels!).
  2770. //
  2771. // <pre>
  2772. // var views = [
  2773. // {
  2774. // id: 'grid', // used for routing
  2775. // label: 'Grid', // used for view switcher
  2776. // view: new recline.View.Grid({
  2777. // model: dataset
  2778. // })
  2779. // },
  2780. // {
  2781. // id: 'graph',
  2782. // label: 'Graph',
  2783. // view: new recline.View.Graph({
  2784. // model: dataset
  2785. // })
  2786. // }
  2787. // ];
  2788. // </pre>
  2789. //
  2790. // **sidebarViews**: (optional) the sidebar views (Filters, Fields) for
  2791. // MultiView to show. This is an array of view hashes. If not provided
  2792. // initialize with (recline.View.)FilterEditor and Fields views (with obvious
  2793. // id and labels!).
  2794. //
  2795. // <pre>
  2796. // var sidebarViews = [
  2797. // {
  2798. // id: 'filterEditor', // used for routing
  2799. // label: 'Filters', // used for view switcher
  2800. // view: new recline.View.FielterEditor({
  2801. // model: dataset
  2802. // })
  2803. // },
  2804. // {
  2805. // id: 'fieldsView',
  2806. // label: 'Fields',
  2807. // view: new recline.View.Fields({
  2808. // model: dataset
  2809. // })
  2810. // }
  2811. // ];
  2812. // </pre>
  2813. //
  2814. // **state**: standard state config for this view. This state is slightly
  2815. // special as it includes config of many of the subviews.
  2816. //
  2817. // <pre>
  2818. // state = {
  2819. // query: {dataset query state - see dataset.queryState object}
  2820. // view-{id1}: {view-state for this view}
  2821. // view-{id2}: {view-state for }
  2822. // ...
  2823. // // Explorer
  2824. // currentView: id of current view (defaults to first view if not specified)
  2825. // readOnly: (default: false) run in read-only mode
  2826. // }
  2827. // </pre>
  2828. //
  2829. // Note that at present we do *not* serialize information about the actual set
  2830. // of views in use -- e.g. those specified by the views argument -- but instead
  2831. // expect either that the default views are fine or that the client to have
  2832. // initialized the MultiView with the relevant views themselves.
  2833. my.MultiView = Backbone.View.extend({
  2834. template: ' \
  2835. <div class="recline-data-explorer"> \
  2836. <div class="alert-messages"></div> \
  2837. \
  2838. <div class="header"> \
  2839. <div class="navigation"> \
  2840. <div class="btn-group" data-toggle="buttons-radio"> \
  2841. {{#views}} \
  2842. <a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
  2843. {{/views}} \
  2844. </div> \
  2845. </div> \
  2846. <div class="recline-results-info"> \
  2847. <span class="doc-count">{{recordCount}}</span> records\
  2848. </div> \
  2849. <div class="menu-right"> \
  2850. <div class="btn-group" data-toggle="buttons-checkbox"> \
  2851. {{#sidebarViews}} \
  2852. <a href="#" data-action="{{id}}" class="btn active">{{label}}</a> \
  2853. {{/sidebarViews}} \
  2854. </div> \
  2855. </div> \
  2856. <div class="query-editor-here" style="display:inline;"></div> \
  2857. <div class="clearfix"></div> \
  2858. </div> \
  2859. <div class="data-view-sidebar"></div> \
  2860. <div class="data-view-container"></div> \
  2861. </div> \
  2862. ',
  2863. events: {
  2864. 'click .menu-right a': '_onMenuClick',
  2865. 'click .navigation a': '_onSwitchView'
  2866. },
  2867. initialize: function(options) {
  2868. var self = this;
  2869. this.el = $(this.el);
  2870. this._setupState(options.state);
  2871. // Hash of 'page' views (i.e. those for whole page) keyed by page name
  2872. if (options.views) {
  2873. this.pageViews = options.views;
  2874. } else {
  2875. this.pageViews = [{
  2876. id: 'grid',
  2877. label: 'Grid',
  2878. view: new my.SlickGrid({
  2879. model: this.model,
  2880. state: this.state.get('view-grid')
  2881. }),
  2882. }, {
  2883. id: 'graph',
  2884. label: 'Graph',
  2885. view: new my.Graph({
  2886. model: this.model,
  2887. state: this.state.get('view-graph')
  2888. }),
  2889. }, {
  2890. id: 'map',
  2891. label: 'Map',
  2892. view: new my.Map({
  2893. model: this.model,
  2894. state: this.state.get('view-map')
  2895. }),
  2896. }, {
  2897. id: 'timeline',
  2898. label: 'Timeline',
  2899. view: new my.Timeline({
  2900. model: this.model,
  2901. state: this.state.get('view-timeline')
  2902. }),
  2903. }, {
  2904. id: 'transform',
  2905. label: 'Transform',
  2906. view: new my.Transform({
  2907. model: this.model
  2908. })
  2909. }];
  2910. }
  2911. // Hashes of sidebar elements
  2912. if(options.sidebarViews) {
  2913. this.sidebarViews = options.sidebarViews;
  2914. } else {
  2915. this.sidebarViews = [{
  2916. id: 'filterEditor',
  2917. label: 'Filters',
  2918. view: new my.FilterEditor({
  2919. model: this.model
  2920. })
  2921. }, {
  2922. id: 'fieldsView',
  2923. label: 'Fields',
  2924. view: new my.Fields({
  2925. model: this.model
  2926. })
  2927. }];
  2928. }
  2929. // these must be called after pageViews are created
  2930. this.render();
  2931. this._bindStateChanges();
  2932. this._bindFlashNotifications();
  2933. // now do updates based on state (need to come after render)
  2934. if (this.state.get('readOnly')) {
  2935. this.setReadOnly();
  2936. }
  2937. if (this.state.get('currentView')) {
  2938. this.updateNav(this.state.get('currentView'));
  2939. } else {
  2940. this.updateNav(this.pageViews[0].id);
  2941. }
  2942. this.model.bind('query:start', function() {
  2943. self.notify({loader: true, persist: true});
  2944. });
  2945. this.model.bind('query:done', function() {
  2946. self.clearNotifications();
  2947. self.el.find('.doc-count').text(self.model.recordCount || 'Unknown');
  2948. });
  2949. this.model.bind('query:fail', function(error) {
  2950. self.clearNotifications();
  2951. var msg = '';
  2952. if (typeof(error) == 'string') {
  2953. msg = error;
  2954. } else if (typeof(error) == 'object') {
  2955. if (error.title) {
  2956. msg = error.title + ': ';
  2957. }
  2958. if (error.message) {
  2959. msg += error.message;
  2960. }
  2961. } else {
  2962. msg = 'There was an error querying the backend';
  2963. }
  2964. self.notify({message: msg, category: 'error', persist: true});
  2965. });
  2966. // retrieve basic data like fields etc
  2967. // note this.model and dataset returned are the same
  2968. // TODO: set query state ...?
  2969. this.model.queryState.set(self.state.get('query'), {silent: true});
  2970. this.model.fetch()
  2971. .fail(function(error) {
  2972. self.notify({message: error.message, category: 'error', persist: true});
  2973. });
  2974. },
  2975. setReadOnly: function() {
  2976. this.el.addClass('recline-read-only');
  2977. },
  2978. render: function() {
  2979. var tmplData = this.model.toTemplateJSON();
  2980. tmplData.views = this.pageViews;
  2981. tmplData.sidebarViews = this.sidebarViews;
  2982. var template = Mustache.render(this.template, tmplData);
  2983. $(this.el).html(template);
  2984. // now create and append other views
  2985. var $dataViewContainer = this.el.find('.data-view-container');
  2986. var $dataSidebar = this.el.find('.data-view-sidebar');
  2987. // the main views
  2988. _.each(this.pageViews, function(view, pageName) {
  2989. view.view.render();
  2990. $dataViewContainer.append(view.view.el);
  2991. if (view.view.elSidebar) {
  2992. $dataSidebar.append(view.view.elSidebar);
  2993. }
  2994. });
  2995. _.each(this.sidebarViews, function(view) {
  2996. this['$'+view.id] = view.view.el;
  2997. $dataSidebar.append(view.view.el);
  2998. }, this);
  2999. var pager = new recline.View.Pager({
  3000. model: this.model.queryState
  3001. });
  3002. this.el.find('.recline-results-info').after(pager.el);
  3003. var queryEditor = new recline.View.QueryEditor({
  3004. model: this.model.queryState
  3005. });
  3006. this.el.find('.query-editor-here').append(queryEditor.el);
  3007. },
  3008. updateNav: function(pageName) {
  3009. this.el.find('.navigation a').removeClass('active');
  3010. var $el = this.el.find('.navigation a[data-view="' + pageName + '"]');
  3011. $el.addClass('active');
  3012. // show the specific page
  3013. _.each(this.pageViews, function(view, idx) {
  3014. if (view.id === pageName) {
  3015. view.view.el.show();
  3016. if (view.view.elSidebar) {
  3017. view.view.elSidebar.show();
  3018. }
  3019. if (view.view.show) {
  3020. view.view.show();
  3021. }
  3022. } else {
  3023. view.view.el.hide();
  3024. if (view.view.elSidebar) {
  3025. view.view.elSidebar.hide();
  3026. }
  3027. if (view.view.hide) {
  3028. view.view.hide();
  3029. }
  3030. }
  3031. });
  3032. },
  3033. _onMenuClick: function(e) {
  3034. e.preventDefault();
  3035. var action = $(e.target).attr('data-action');
  3036. this['$'+action].toggle();
  3037. },
  3038. _onSwitchView: function(e) {
  3039. e.preventDefault();
  3040. var viewName = $(e.target).attr('data-view');
  3041. this.updateNav(viewName);
  3042. this.state.set({currentView: viewName});
  3043. },
  3044. // create a state object for this view and do the job of
  3045. //
  3046. // a) initializing it from both data passed in and other sources (e.g. hash url)
  3047. //
  3048. // b) ensure the state object is updated in responese to changes in subviews, query etc.
  3049. _setupState: function(initialState) {
  3050. var self = this;
  3051. // get data from the query string / hash url plus some defaults
  3052. var qs = my.parseHashQueryString();
  3053. var query = qs.reclineQuery;
  3054. query = query ? JSON.parse(query) : self.model.queryState.toJSON();
  3055. // backwards compatability (now named view-graph but was named graph)
  3056. var graphState = qs['view-graph'] || qs.graph;
  3057. graphState = graphState ? JSON.parse(graphState) : {};
  3058. // now get default data + hash url plus initial state and initial our state object with it
  3059. var stateData = _.extend({
  3060. query: query,
  3061. 'view-graph': graphState,
  3062. backend: this.model.backend.__type__,
  3063. url: this.model.get('url'),
  3064. dataset: this.model.toJSON(),
  3065. currentView: null,
  3066. readOnly: false
  3067. },
  3068. initialState);
  3069. this.state = new recline.Model.ObjectState(stateData);
  3070. },
  3071. _bindStateChanges: function() {
  3072. var self = this;
  3073. // finally ensure we update our state object when state of sub-object changes so that state is always up to date
  3074. this.model.queryState.bind('change', function() {
  3075. self.state.set({query: self.model.queryState.toJSON()});
  3076. });
  3077. _.each(this.pageViews, function(pageView) {
  3078. if (pageView.view.state && pageView.view.state.bind) {
  3079. var update = {};
  3080. update['view-' + pageView.id] = pageView.view.state.toJSON();
  3081. self.state.set(update);
  3082. pageView.view.state.bind('change', function() {
  3083. var update = {};
  3084. update['view-' + pageView.id] = pageView.view.state.toJSON();
  3085. // had problems where change not being triggered for e.g. grid view so let's do it explicitly
  3086. self.state.set(update, {silent: true});
  3087. self.state.trigger('change');
  3088. });
  3089. }
  3090. });
  3091. },
  3092. _bindFlashNotifications: function() {
  3093. var self = this;
  3094. _.each(this.pageViews, function(pageView) {
  3095. pageView.view.bind('recline:flash', function(flash) {
  3096. self.notify(flash);
  3097. });
  3098. });
  3099. },
  3100. // ### notify
  3101. //
  3102. // Create a notification (a div.alert in div.alert-messsages) using provided
  3103. // flash object. Flash attributes (all are optional):
  3104. //
  3105. // * message: message to show.
  3106. // * category: warning (default), success, error
  3107. // * persist: if true alert is persistent, o/w hidden after 3s (default = false)
  3108. // * loader: if true show loading spinner
  3109. notify: function(flash) {
  3110. var tmplData = _.extend({
  3111. message: 'Loading',
  3112. category: 'warning',
  3113. loader: false
  3114. },
  3115. flash
  3116. );
  3117. if (tmplData.loader) {
  3118. var _template = ' \
  3119. <div class="alert alert-info alert-loader"> \
  3120. {{message}} \
  3121. <span class="notification-loader">&nbsp;</span> \
  3122. </div>';
  3123. } else {
  3124. var _template = ' \
  3125. <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
  3126. {{message}} \
  3127. </div>';
  3128. }
  3129. var _templated = $(Mustache.render(_template, tmplData));
  3130. _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
  3131. if (!flash.persist) {
  3132. setTimeout(function() {
  3133. $(_templated).fadeOut(1000, function() {
  3134. $(this).remove();
  3135. });
  3136. }, 1000);
  3137. }
  3138. },
  3139. // ### clearNotifications
  3140. //
  3141. // Clear all existing notifications
  3142. clearNotifications: function() {
  3143. var $notifications = $('.recline-data-explorer .alert-messages .alert');
  3144. $notifications.fadeOut(1500, function() {
  3145. $(this).remove();
  3146. });
  3147. }
  3148. });
  3149. // ### MultiView.restore
  3150. //
  3151. // Restore a MultiView instance from a serialized state including the associated dataset
  3152. //
  3153. // This inverts the state serialization process in Multiview
  3154. my.MultiView.restore = function(state) {
  3155. // hack-y - restoring a memory dataset does not mean much ... (but useful for testing!)
  3156. if (state.backend === 'memory') {
  3157. var datasetInfo = {
  3158. backend: 'memory',
  3159. records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}]
  3160. };
  3161. } else {
  3162. var datasetInfo = _.extend({
  3163. url: state.url,
  3164. backend: state.backend
  3165. },
  3166. state.dataset
  3167. );
  3168. }
  3169. var dataset = new recline.Model.Dataset(datasetInfo);
  3170. var explorer = new my.MultiView({
  3171. model: dataset,
  3172. state: state
  3173. });
  3174. return explorer;
  3175. }
  3176. // ## Miscellaneous Utilities
  3177. var urlPathRegex = /^([^?]+)(\?.*)?/;
  3178. // Parse the Hash section of a URL into path and query string
  3179. my.parseHashUrl = function(hashUrl) {
  3180. var parsed = urlPathRegex.exec(hashUrl);
  3181. if (parsed === null) {
  3182. return {};
  3183. } else {
  3184. return {
  3185. path: parsed[1],
  3186. query: parsed[2] || ''
  3187. };
  3188. }
  3189. };
  3190. // Parse a URL query string (?xyz=abc...) into a dictionary.
  3191. my.parseQueryString = function(q) {
  3192. if (!q) {
  3193. return {};
  3194. }
  3195. var urlParams = {},
  3196. e, d = function (s) {
  3197. return unescape(s.replace(/\+/g, " "));
  3198. },
  3199. r = /([^&=]+)=?([^&]*)/g;
  3200. if (q && q.length && q[0] === '?') {
  3201. q = q.slice(1);
  3202. }
  3203. while (e = r.exec(q)) {
  3204. // TODO: have values be array as query string allow repetition of keys
  3205. urlParams[d(e[1])] = d(e[2]);
  3206. }
  3207. return urlParams;
  3208. };
  3209. // Parse the query string out of the URL hash
  3210. my.parseHashQueryString = function() {
  3211. q = my.parseHashUrl(window.location.hash).query;
  3212. return my.parseQueryString(q);
  3213. };
  3214. // Compse a Query String
  3215. my.composeQueryString = function(queryParams) {
  3216. var queryString = '?';
  3217. var items = [];
  3218. $.each(queryParams, function(key, value) {
  3219. if (typeof(value) === 'object') {
  3220. value = JSON.stringify(value);
  3221. }
  3222. items.push(key + '=' + encodeURIComponent(value));
  3223. });
  3224. queryString += items.join('&');
  3225. return queryString;
  3226. };
  3227. my.getNewHashForQueryString = function(queryParams) {
  3228. var queryPart = my.composeQueryString(queryParams);
  3229. if (window.location.hash) {
  3230. // slice(1) to remove # at start
  3231. return window.location.hash.split('?')[0].slice(1) + queryPart;
  3232. } else {
  3233. return queryPart;
  3234. }
  3235. };
  3236. my.setHashQueryString = function(queryParams) {
  3237. window.location.hash = my.getNewHashForQueryString(queryParams);
  3238. };
  3239. })(jQuery, recline.View);
  3240. /*jshint multistr:true */
  3241. this.recline = this.recline || {};
  3242. this.recline.View = this.recline.View || {};
  3243. (function($, my) {
  3244. // ## SlickGrid Dataset View
  3245. //
  3246. // Provides a tabular view on a Dataset, based on SlickGrid.
  3247. //
  3248. // https://github.com/mleibman/SlickGrid
  3249. //
  3250. // Initialize it with a `recline.Model.Dataset`.
  3251. //
  3252. // NB: you need an explicit height on the element for slickgrid to work
  3253. my.SlickGrid = Backbone.View.extend({
  3254. initialize: function(modelEtc) {
  3255. var self = this;
  3256. this.el = $(this.el);
  3257. this.el.addClass('recline-slickgrid');
  3258. _.bindAll(this, 'render');
  3259. this.model.records.bind('add', this.render);
  3260. this.model.records.bind('reset', this.render);
  3261. this.model.records.bind('remove', this.render);
  3262. var state = _.extend({
  3263. hiddenColumns: [],
  3264. columnsOrder: [],
  3265. columnsSort: {},
  3266. columnsWidth: [],
  3267. fitColumns: false
  3268. }, modelEtc.state
  3269. );
  3270. this.state = new recline.Model.ObjectState(state);
  3271. },
  3272. events: {
  3273. },
  3274. render: function() {
  3275. var self = this;
  3276. var options = {
  3277. enableCellNavigation: true,
  3278. enableColumnReorder: true,
  3279. explicitInitialization: true,
  3280. syncColumnCellResize: true,
  3281. forceFitColumns: this.state.get('fitColumns')
  3282. };
  3283. // We need all columns, even the hidden ones, to show on the column picker
  3284. var columns = [];
  3285. // custom formatter as default one escapes html
  3286. // plus this way we distinguish between rendering/formatting and computed value (so e.g. sort still works ...)
  3287. // row = row index, cell = cell index, value = value, columnDef = column definition, dataContext = full row values
  3288. var formatter = function(row, cell, value, columnDef, dataContext) {
  3289. var field = self.model.fields.get(columnDef.id);
  3290. if (field.renderer) {
  3291. return field.renderer(value, field, dataContext);
  3292. } else {
  3293. return value;
  3294. }
  3295. }
  3296. _.each(this.model.fields.toJSON(),function(field){
  3297. var column = {
  3298. id:field['id'],
  3299. name:field['label'],
  3300. field:field['id'],
  3301. sortable: true,
  3302. minWidth: 80,
  3303. formatter: formatter
  3304. };
  3305. var widthInfo = _.find(self.state.get('columnsWidth'),function(c){return c.column == field.id});
  3306. if (widthInfo){
  3307. column['width'] = widthInfo.width;
  3308. }
  3309. columns.push(column);
  3310. });
  3311. // Restrict the visible columns
  3312. var visibleColumns = columns.filter(function(column) {
  3313. return _.indexOf(self.state.get('hiddenColumns'), column.id) == -1;
  3314. });
  3315. // Order them if there is ordering info on the state
  3316. if (this.state.get('columnsOrder')){
  3317. visibleColumns = visibleColumns.sort(function(a,b){
  3318. return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1;
  3319. });
  3320. columns = columns.sort(function(a,b){
  3321. return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1;
  3322. });
  3323. }
  3324. // Move hidden columns to the end, so they appear at the bottom of the
  3325. // column picker
  3326. var tempHiddenColumns = [];
  3327. for (var i = columns.length -1; i >= 0; i--){
  3328. if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) == -1){
  3329. tempHiddenColumns.push(columns.splice(i,1)[0]);
  3330. }
  3331. }
  3332. columns = columns.concat(tempHiddenColumns);
  3333. var data = [];
  3334. this.model.records.each(function(doc){
  3335. var row = {};
  3336. self.model.fields.each(function(field){
  3337. row[field.id] = doc.getFieldValueUnrendered(field);
  3338. });
  3339. data.push(row);
  3340. });
  3341. this.grid = new Slick.Grid(this.el, data, visibleColumns, options);
  3342. // Column sorting
  3343. var sortInfo = this.model.queryState.get('sort');
  3344. if (sortInfo){
  3345. var column = sortInfo[0].field;
  3346. var sortAsc = !(sortInfo[0].order == 'desc');
  3347. this.grid.setSortColumn(column, sortAsc);
  3348. }
  3349. this.grid.onSort.subscribe(function(e, args){
  3350. var order = (args.sortAsc) ? 'asc':'desc';
  3351. var sort = [{
  3352. field: args.sortCol.field,
  3353. order: order
  3354. }];
  3355. self.model.query({sort: sort});
  3356. });
  3357. this.grid.onColumnsReordered.subscribe(function(e, args){
  3358. self.state.set({columnsOrder: _.pluck(self.grid.getColumns(),'id')});
  3359. });
  3360. this.grid.onColumnsResized.subscribe(function(e, args){
  3361. var columns = args.grid.getColumns();
  3362. var defaultColumnWidth = args.grid.getOptions().defaultColumnWidth;
  3363. var columnsWidth = [];
  3364. _.each(columns,function(column){
  3365. if (column.width != defaultColumnWidth){
  3366. columnsWidth.push({column:column.id,width:column.width});
  3367. }
  3368. });
  3369. self.state.set({columnsWidth:columnsWidth});
  3370. });
  3371. var columnpicker = new Slick.Controls.ColumnPicker(columns, this.grid,
  3372. _.extend(options,{state:this.state}));
  3373. if (self.visible){
  3374. self.grid.init();
  3375. self.rendered = true;
  3376. } else {
  3377. // Defer rendering until the view is visible
  3378. self.rendered = false;
  3379. }
  3380. return this;
  3381. },
  3382. show: function() {
  3383. // If the div is hidden, SlickGrid will calculate wrongly some
  3384. // sizes so we must render it explicitly when the view is visible
  3385. if (!this.rendered){
  3386. if (!this.grid){
  3387. this.render();
  3388. }
  3389. this.grid.init();
  3390. this.rendered = true;
  3391. }
  3392. this.visible = true;
  3393. },
  3394. hide: function() {
  3395. this.visible = false;
  3396. }
  3397. });
  3398. })(jQuery, recline.View);
  3399. /*
  3400. * Context menu for the column picker, adapted from
  3401. * http://mleibman.github.com/SlickGrid/examples/example-grouping
  3402. *
  3403. */
  3404. (function ($) {
  3405. function SlickColumnPicker(columns, grid, options) {
  3406. var $menu;
  3407. var columnCheckboxes;
  3408. var defaults = {
  3409. fadeSpeed:250
  3410. };
  3411. function init() {
  3412. grid.onHeaderContextMenu.subscribe(handleHeaderContextMenu);
  3413. options = $.extend({}, defaults, options);
  3414. $menu = $('<ul class="dropdown-menu slick-contextmenu" style="display:none;position:absolute;z-index:20;" />').appendTo(document.body);
  3415. $menu.bind('mouseleave', function (e) {
  3416. $(this).fadeOut(options.fadeSpeed)
  3417. });
  3418. $menu.bind('click', updateColumn);
  3419. }
  3420. function handleHeaderContextMenu(e, args) {
  3421. e.preventDefault();
  3422. $menu.empty();
  3423. columnCheckboxes = [];
  3424. var $li, $input;
  3425. for (var i = 0; i < columns.length; i++) {
  3426. $li = $('<li />').appendTo($menu);
  3427. $input = $('<input type="checkbox" />').data('column-id', columns[i].id).attr('id','slick-column-vis-'+columns[i].id);
  3428. columnCheckboxes.push($input);
  3429. if (grid.getColumnIndex(columns[i].id) != null) {
  3430. $input.attr('checked', 'checked');
  3431. }
  3432. $input.appendTo($li);
  3433. $('<label />')
  3434. .text(columns[i].name)
  3435. .attr('for','slick-column-vis-'+columns[i].id)
  3436. .appendTo($li);
  3437. }
  3438. $('<li/>').addClass('divider').appendTo($menu);
  3439. $li = $('<li />').data('option', 'autoresize').appendTo($menu);
  3440. $input = $('<input type="checkbox" />').data('option', 'autoresize').attr('id','slick-option-autoresize');
  3441. $input.appendTo($li);
  3442. $('<label />')
  3443. .text('Force fit columns')
  3444. .attr('for','slick-option-autoresize')
  3445. .appendTo($li);
  3446. if (grid.getOptions().forceFitColumns) {
  3447. $input.attr('checked', 'checked');
  3448. }
  3449. $menu.css('top', e.pageY - 10)
  3450. .css('left', e.pageX - 10)
  3451. .fadeIn(options.fadeSpeed);
  3452. }
  3453. function updateColumn(e) {
  3454. if ($(e.target).data('option') == 'autoresize') {
  3455. var checked;
  3456. if ($(e.target).is('li')){
  3457. var checkbox = $(e.target).find('input').first();
  3458. checked = !checkbox.is(':checked');
  3459. checkbox.attr('checked',checked);
  3460. } else {
  3461. checked = e.target.checked;
  3462. }
  3463. if (checked) {
  3464. grid.setOptions({forceFitColumns:true});
  3465. grid.autosizeColumns();
  3466. } else {
  3467. grid.setOptions({forceFitColumns:false});
  3468. }
  3469. options.state.set({fitColumns:checked});
  3470. return;
  3471. }
  3472. if (($(e.target).is('li') && !$(e.target).hasClass('divider')) ||
  3473. $(e.target).is('input')) {
  3474. if ($(e.target).is('li')){
  3475. var checkbox = $(e.target).find('input').first();
  3476. checkbox.attr('checked',!checkbox.is(':checked'));
  3477. }
  3478. var visibleColumns = [];
  3479. var hiddenColumnsIds = [];
  3480. $.each(columnCheckboxes, function (i, e) {
  3481. if ($(this).is(':checked')) {
  3482. visibleColumns.push(columns[i]);
  3483. } else {
  3484. hiddenColumnsIds.push(columns[i].id);
  3485. }
  3486. });
  3487. if (!visibleColumns.length) {
  3488. $(e.target).attr('checked', 'checked');
  3489. return;
  3490. }
  3491. grid.setColumns(visibleColumns);
  3492. options.state.set({hiddenColumns:hiddenColumnsIds});
  3493. }
  3494. }
  3495. init();
  3496. }
  3497. // Slick.Controls.ColumnPicker
  3498. $.extend(true, window, { Slick:{ Controls:{ ColumnPicker:SlickColumnPicker }}});
  3499. })(jQuery);
  3500. /*jshint multistr:true */
  3501. this.recline = this.recline || {};
  3502. this.recline.View = this.recline.View || {};
  3503. (function($, my) {
  3504. // turn off unnecessary logging from VMM Timeline
  3505. if (typeof VMM !== 'undefined') {
  3506. VMM.debug = false;
  3507. }
  3508. // ## Timeline
  3509. //
  3510. // Timeline view using http://timeline.verite.co/
  3511. my.Timeline = Backbone.View.extend({
  3512. template: ' \
  3513. <div class="recline-timeline"> \
  3514. <div id="vmm-timeline-id"></div> \
  3515. </div> \
  3516. ',
  3517. // These are the default (case-insensitive) names of field that are used if found.
  3518. // If not found, the user will need to define these fields on initialization
  3519. startFieldNames: ['date','startdate', 'start', 'start-date'],
  3520. endFieldNames: ['end','endDate'],
  3521. elementId: '#vmm-timeline-id',
  3522. initialize: function(options) {
  3523. var self = this;
  3524. this.el = $(this.el);
  3525. this.timeline = new VMM.Timeline();
  3526. this._timelineIsInitialized = false;
  3527. this.model.fields.bind('reset', function() {
  3528. self._setupTemporalField();
  3529. });
  3530. this.model.records.bind('all', function() {
  3531. self.reloadData();
  3532. });
  3533. var stateData = _.extend({
  3534. startField: null,
  3535. endField: null
  3536. },
  3537. options.state
  3538. );
  3539. this.state = new recline.Model.ObjectState(stateData);
  3540. this._setupTemporalField();
  3541. },
  3542. render: function() {
  3543. var tmplData = {};
  3544. var htmls = Mustache.render(this.template, tmplData);
  3545. this.el.html(htmls);
  3546. // can only call _initTimeline once view in DOM as Timeline uses $
  3547. // internally to look up element
  3548. if ($(this.elementId).length > 0) {
  3549. this._initTimeline();
  3550. }
  3551. },
  3552. show: function() {
  3553. // only call _initTimeline once view in DOM as Timeline uses $ internally to look up element
  3554. if (this._timelineIsInitialized === false) {
  3555. this._initTimeline();
  3556. }
  3557. },
  3558. _initTimeline: function() {
  3559. var $timeline = this.el.find(this.elementId);
  3560. // set width explicitly o/w timeline goes wider that screen for some reason
  3561. var width = Math.max(this.el.width(), this.el.find('.recline-timeline').width());
  3562. if (width) {
  3563. $timeline.width(width);
  3564. }
  3565. var config = {};
  3566. var data = this._timelineJSON();
  3567. this.timeline.init(data, this.elementId, config);
  3568. this._timelineIsInitialized = true
  3569. },
  3570. reloadData: function() {
  3571. if (this._timelineIsInitialized) {
  3572. var data = this._timelineJSON();
  3573. this.timeline.reload(data);
  3574. }
  3575. },
  3576. // Convert record to JSON for timeline
  3577. //
  3578. // Designed to be overridden in client apps
  3579. convertRecord: function(record, fields) {
  3580. return this._convertRecord(record, fields);
  3581. },
  3582. // Internal method to generate a Timeline formatted entry
  3583. _convertRecord: function(record, fields) {
  3584. var start = this._parseDate(record.get(this.state.get('startField')));
  3585. var end = this._parseDate(record.get(this.state.get('endField')));
  3586. if (start) {
  3587. var tlEntry = {
  3588. "startDate": start,
  3589. "endDate": end,
  3590. "headline": String(record.get('title') || ''),
  3591. "text": record.get('description') || record.summary()
  3592. };
  3593. return tlEntry;
  3594. } else {
  3595. return null;
  3596. }
  3597. },
  3598. _timelineJSON: function() {
  3599. var self = this;
  3600. var out = {
  3601. 'timeline': {
  3602. 'type': 'default',
  3603. 'headline': '',
  3604. 'date': [
  3605. ]
  3606. }
  3607. };
  3608. this.model.records.each(function(record) {
  3609. var newEntry = self.convertRecord(record, self.fields);
  3610. if (newEntry) {
  3611. out.timeline.date.push(newEntry);
  3612. }
  3613. });
  3614. // if no entries create a placeholder entry to prevent Timeline crashing with error
  3615. if (out.timeline.date.length === 0) {
  3616. var tlEntry = {
  3617. "startDate": '2000,1,1',
  3618. "headline": 'No data to show!'
  3619. };
  3620. out.timeline.date.push(tlEntry);
  3621. }
  3622. return out;
  3623. },
  3624. _parseDate: function(date) {
  3625. if (!date) {
  3626. return null;
  3627. }
  3628. var out = date.trim();
  3629. out = out.replace(/(\d)th/g, '$1');
  3630. out = out.replace(/(\d)st/g, '$1');
  3631. out = out.trim() ? moment(out) : null;
  3632. if (out.toDate() == 'Invalid Date') {
  3633. return null;
  3634. } else {
  3635. // fix for moment weirdness around date parsing and time zones
  3636. // moment('1914-08-01').toDate() => 1914-08-01 00:00 +01:00
  3637. // which in iso format (with 0 time offset) is 31 July 1914 23:00
  3638. // meanwhile native new Date('1914-08-01') => 1914-08-01 01:00 +01:00
  3639. out = out.subtract('minutes', out.zone());
  3640. return out.toDate();
  3641. }
  3642. },
  3643. _setupTemporalField: function() {
  3644. this.state.set({
  3645. startField: this._checkField(this.startFieldNames),
  3646. endField: this._checkField(this.endFieldNames)
  3647. });
  3648. },
  3649. _checkField: function(possibleFieldNames) {
  3650. var modelFieldNames = this.model.fields.pluck('id');
  3651. for (var i = 0; i < possibleFieldNames.length; i++){
  3652. for (var j = 0; j < modelFieldNames.length; j++){
  3653. if (modelFieldNames[j].toLowerCase() == possibleFieldNames[i].toLowerCase())
  3654. return modelFieldNames[j];
  3655. }
  3656. }
  3657. return null;
  3658. }
  3659. });
  3660. })(jQuery, recline.View);
  3661. /*jshint multistr:true */
  3662. this.recline = this.recline || {};
  3663. this.recline.View = this.recline.View || {};
  3664. // Views module following classic module pattern
  3665. (function($, my) {
  3666. // ## ColumnTransform
  3667. //
  3668. // View (Dialog) for doing data transformations
  3669. my.Transform = Backbone.View.extend({
  3670. template: ' \
  3671. <div class="recline-transform"> \
  3672. <div class="script"> \
  3673. <h2> \
  3674. Transform Script \
  3675. <button class="okButton btn btn-primary">Run on all records</button> \
  3676. </h2> \
  3677. <textarea class="expression-preview-code"></textarea> \
  3678. </div> \
  3679. <div class="expression-preview-parsing-status"> \
  3680. No syntax error. \
  3681. </div> \
  3682. <div class="preview"> \
  3683. <h3>Preview</h3> \
  3684. <div class="expression-preview-container"></div> \
  3685. </div> \
  3686. </div> \
  3687. ',
  3688. events: {
  3689. 'click .okButton': 'onSubmit',
  3690. 'keydown .expression-preview-code': 'onEditorKeydown'
  3691. },
  3692. initialize: function(options) {
  3693. this.el = $(this.el);
  3694. },
  3695. render: function() {
  3696. var htmls = Mustache.render(this.template);
  3697. this.el.html(htmls);
  3698. // Put in the basic (identity) transform script
  3699. // TODO: put this into the template?
  3700. var editor = this.el.find('.expression-preview-code');
  3701. if (this.model.fields.length > 0) {
  3702. var col = this.model.fields.models[0].id;
  3703. } else {
  3704. var col = 'unknown';
  3705. }
  3706. editor.val("function(doc) {\n doc['"+ col +"'] = doc['"+ col +"'];\n return doc;\n}");
  3707. editor.keydown();
  3708. },
  3709. onSubmit: function(e) {
  3710. var self = this;
  3711. var funcText = this.el.find('.expression-preview-code').val();
  3712. var editFunc = recline.Data.Transform.evalFunction(funcText);
  3713. if (editFunc.errorMessage) {
  3714. this.trigger('recline:flash', {message: "Error with function! " + editFunc.errorMessage});
  3715. return;
  3716. }
  3717. this.model.transform(editFunc);
  3718. },
  3719. editPreviewTemplate: ' \
  3720. <table class="table table-condensed table-bordered before-after"> \
  3721. <thead> \
  3722. <tr> \
  3723. <th>Field</th> \
  3724. <th>Before</th> \
  3725. <th>After</th> \
  3726. </tr> \
  3727. </thead> \
  3728. <tbody> \
  3729. {{#row}} \
  3730. <tr> \
  3731. <td> \
  3732. {{field}} \
  3733. </td> \
  3734. <td class="before {{#different}}different{{/different}}"> \
  3735. {{before}} \
  3736. </td> \
  3737. <td class="after {{#different}}different{{/different}}"> \
  3738. {{after}} \
  3739. </td> \
  3740. </tr> \
  3741. {{/row}} \
  3742. </tbody> \
  3743. </table> \
  3744. ',
  3745. onEditorKeydown: function(e) {
  3746. var self = this;
  3747. // if you don't setTimeout it won't grab the latest character if you call e.target.value
  3748. window.setTimeout( function() {
  3749. var errors = self.el.find('.expression-preview-parsing-status');
  3750. var editFunc = recline.Data.Transform.evalFunction(e.target.value);
  3751. if (!editFunc.errorMessage) {
  3752. errors.text('No syntax error.');
  3753. var docs = self.model.records.map(function(doc) {
  3754. return doc.toJSON();
  3755. });
  3756. var previewData = recline.Data.Transform.previewTransform(docs, editFunc);
  3757. var $el = self.el.find('.expression-preview-container');
  3758. var fields = self.model.fields.toJSON();
  3759. var rows = _.map(previewData.slice(0,4), function(row) {
  3760. return _.map(fields, function(field) {
  3761. return {
  3762. field: field.id,
  3763. before: row.before[field.id],
  3764. after: row.after[field.id],
  3765. different: !_.isEqual(row.before[field.id], row.after[field.id])
  3766. }
  3767. });
  3768. });
  3769. $el.html('');
  3770. _.each(rows, function(row) {
  3771. var templated = Mustache.render(self.editPreviewTemplate, {
  3772. row: row
  3773. });
  3774. $el.append(templated);
  3775. });
  3776. } else {
  3777. errors.text(editFunc.errorMessage);
  3778. }
  3779. }, 1, true);
  3780. }
  3781. });
  3782. })(jQuery, recline.View);
  3783. /*jshint multistr:true */
  3784. this.recline = this.recline || {};
  3785. this.recline.View = this.recline.View || {};
  3786. (function($, my) {
  3787. my.FacetViewer = Backbone.View.extend({
  3788. className: 'recline-facet-viewer well',
  3789. template: ' \
  3790. <a class="close js-hide" href="#">&times;</a> \
  3791. <div class="facets row"> \
  3792. <div class="span1"> \
  3793. <h3>Facets</h3> \
  3794. </div> \
  3795. {{#facets}} \
  3796. <div class="facet-summary span2 dropdown" data-facet="{{id}}"> \
  3797. <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="icon-chevron-down"></i> {{id}} {{label}}</a> \
  3798. <ul class="facet-items dropdown-menu"> \
  3799. {{#terms}} \
  3800. <li><a class="facet-choice js-facet-filter" data-value="{{term}}">{{term}} ({{count}})</a></li> \
  3801. {{/terms}} \
  3802. {{#entries}} \
  3803. <li><a class="facet-choice js-facet-filter" data-value="{{time}}">{{term}} ({{count}})</a></li> \
  3804. {{/entries}} \
  3805. </ul> \
  3806. </div> \
  3807. {{/facets}} \
  3808. </div> \
  3809. ',
  3810. events: {
  3811. 'click .js-hide': 'onHide',
  3812. 'click .js-facet-filter': 'onFacetFilter'
  3813. },
  3814. initialize: function(model) {
  3815. _.bindAll(this, 'render');
  3816. this.el = $(this.el);
  3817. this.model.facets.bind('all', this.render);
  3818. this.model.fields.bind('all', this.render);
  3819. this.render();
  3820. },
  3821. render: function() {
  3822. var tmplData = {
  3823. facets: this.model.facets.toJSON(),
  3824. fields: this.model.fields.toJSON()
  3825. };
  3826. tmplData.facets = _.map(tmplData.facets, function(facet) {
  3827. if (facet._type === 'date_histogram') {
  3828. facet.entries = _.map(facet.entries, function(entry) {
  3829. entry.term = new Date(entry.time).toDateString();
  3830. return entry;
  3831. });
  3832. }
  3833. return facet;
  3834. });
  3835. var templated = Mustache.render(this.template, tmplData);
  3836. this.el.html(templated);
  3837. // are there actually any facets to show?
  3838. if (this.model.facets.length > 0) {
  3839. this.el.show();
  3840. } else {
  3841. this.el.hide();
  3842. }
  3843. },
  3844. onHide: function(e) {
  3845. e.preventDefault();
  3846. this.el.hide();
  3847. },
  3848. onFacetFilter: function(e) {
  3849. var $target= $(e.target);
  3850. var fieldId = $target.closest('.facet-summary').attr('data-facet');
  3851. var value = $target.attr('data-value');
  3852. this.model.queryState.addTermFilter(fieldId, value);
  3853. }
  3854. });
  3855. })(jQuery, recline.View);
  3856. /*jshint multistr:true */
  3857. // Field Info
  3858. //
  3859. // For each field
  3860. //
  3861. // Id / Label / type / format
  3862. // Editor -- to change type (and possibly format)
  3863. // Editor for show/hide ...
  3864. // Summaries of fields
  3865. //
  3866. // Top values / number empty
  3867. // If number: max, min average ...
  3868. // Box to boot transform editor ...
  3869. this.recline = this.recline || {};
  3870. this.recline.View = this.recline.View || {};
  3871. (function($, my) {
  3872. my.Fields = Backbone.View.extend({
  3873. className: 'recline-fields-view',
  3874. template: ' \
  3875. <div class="accordion fields-list well"> \
  3876. <h3>Fields <a href="#" class="js-show-hide">+</a></h3> \
  3877. {{#fields}} \
  3878. <div class="accordion-group field"> \
  3879. <div class="accordion-heading"> \
  3880. <i class="icon-file"></i> \
  3881. <h4> \
  3882. {{label}} \
  3883. <small> \
  3884. {{type}} \
  3885. <a class="accordion-toggle" data-toggle="collapse" href="#collapse{{id}}"> &raquo; </a> \
  3886. </small> \
  3887. </h4> \
  3888. </div> \
  3889. <div id="collapse{{id}}" class="accordion-body collapse in"> \
  3890. <div class="accordion-inner"> \
  3891. {{#facets}} \
  3892. <div class="facet-summary" data-facet="{{id}}"> \
  3893. <ul class="facet-items"> \
  3894. {{#terms}} \
  3895. <li class="facet-item"><span class="term">{{term}}</span> <span class="count">[{{count}}]</span></li> \
  3896. {{/terms}} \
  3897. </ul> \
  3898. </div> \
  3899. {{/facets}} \
  3900. <div class="clear"></div> \
  3901. </div> \
  3902. </div> \
  3903. </div> \
  3904. {{/fields}} \
  3905. </div> \
  3906. ',
  3907. events: {
  3908. 'click .js-show-hide': 'onShowHide'
  3909. },
  3910. initialize: function(model) {
  3911. var self = this;
  3912. this.el = $(this.el);
  3913. _.bindAll(this, 'render');
  3914. // TODO: this is quite restrictive in terms of when it is re-run
  3915. // e.g. a change in type will not trigger a re-run atm.
  3916. // being more liberal (e.g. binding to all) can lead to being called a lot (e.g. for change:width)
  3917. this.model.fields.bind('reset', function(action) {
  3918. self.model.fields.each(function(field) {
  3919. field.facets.unbind('all', self.render);
  3920. field.facets.bind('all', self.render);
  3921. });
  3922. // fields can get reset or changed in which case we need to recalculate
  3923. self.model.getFieldsSummary();
  3924. self.render();
  3925. });
  3926. this.render();
  3927. },
  3928. render: function() {
  3929. var self = this;
  3930. var tmplData = {
  3931. fields: []
  3932. };
  3933. this.model.fields.each(function(field) {
  3934. var out = field.toJSON();
  3935. out.facets = field.facets.toJSON();
  3936. tmplData.fields.push(out);
  3937. });
  3938. var templated = Mustache.render(this.template, tmplData);
  3939. this.el.html(templated);
  3940. this.el.find('.collapse').collapse('hide');
  3941. },
  3942. onShowHide: function(e) {
  3943. e.preventDefault();
  3944. var $target = $(e.target);
  3945. // weird collapse class seems to have been removed (can watch this happen
  3946. // if you watch dom) but could not work why. Absence of collapse then meant
  3947. // we could not toggle.
  3948. // This seems to fix the problem.
  3949. this.el.find('.accordion-body').addClass('collapse');;
  3950. if ($target.text() === '+') {
  3951. this.el.find('.collapse').collapse('show');
  3952. $target.text('-');
  3953. } else {
  3954. this.el.find('.collapse').collapse('hide');
  3955. $target.text('+');
  3956. }
  3957. }
  3958. });
  3959. })(jQuery, recline.View);
  3960. /*jshint multistr:true */
  3961. this.recline = this.recline || {};
  3962. this.recline.View = this.recline.View || {};
  3963. (function($, my) {
  3964. my.FilterEditor = Backbone.View.extend({
  3965. className: 'recline-filter-editor well',
  3966. template: ' \
  3967. <div class="filters"> \
  3968. <h3>Filters</h3> \
  3969. <a href="#" class="js-add-filter">Add filter</a> \
  3970. <form class="form-stacked js-add" style="display: none;"> \
  3971. <fieldset> \
  3972. <label>Filter type</label> \
  3973. <select class="filterType"> \
  3974. <option value="term">Term (text)</option> \
  3975. <option value="range">Range</option> \
  3976. <option value="geo_distance">Geo distance</option> \
  3977. </select> \
  3978. <label>Field</label> \
  3979. <select class="fields"> \
  3980. {{#fields}} \
  3981. <option value="{{id}}">{{label}}</option> \
  3982. {{/fields}} \
  3983. </select> \
  3984. <button type="submit" class="btn">Add</button> \
  3985. </fieldset> \
  3986. </form> \
  3987. <form class="form-stacked js-edit"> \
  3988. {{#filters}} \
  3989. {{{filterRender}}} \
  3990. {{/filters}} \
  3991. {{#filters.length}} \
  3992. <button type="submit" class="btn">Update</button> \
  3993. {{/filters.length}} \
  3994. </form> \
  3995. </div> \
  3996. ',
  3997. filterTemplates: {
  3998. term: ' \
  3999. <div class="filter-{{type}} filter"> \
  4000. <fieldset> \
  4001. <legend> \
  4002. {{field}} <small>{{type}}</small> \
  4003. <a class="js-remove-filter" href="#" title="Remove this filter">&times;</a> \
  4004. </legend> \
  4005. <input type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4006. </fieldset> \
  4007. </div> \
  4008. ',
  4009. range: ' \
  4010. <div class="filter-{{type}} filter"> \
  4011. <fieldset> \
  4012. <legend> \
  4013. {{field}} <small>{{type}}</small> \
  4014. <a class="js-remove-filter" href="#" title="Remove this filter">&times;</a> \
  4015. </legend> \
  4016. <label class="control-label" for="">From</label> \
  4017. <input type="text" value="{{start}}" name="start" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4018. <label class="control-label" for="">To</label> \
  4019. <input type="text" value="{{stop}}" name="stop" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4020. </fieldset> \
  4021. </div> \
  4022. ',
  4023. geo_distance: ' \
  4024. <div class="filter-{{type}} filter"> \
  4025. <fieldset> \
  4026. <legend> \
  4027. {{field}} <small>{{type}}</small> \
  4028. <a class="js-remove-filter" href="#" title="Remove this filter">&times;</a> \
  4029. </legend> \
  4030. <label class="control-label" for="">Longitude</label> \
  4031. <input type="text" value="{{point.lon}}" name="lon" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4032. <label class="control-label" for="">Latitude</label> \
  4033. <input type="text" value="{{point.lat}}" name="lat" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4034. <label class="control-label" for="">Distance (km)</label> \
  4035. <input type="text" value="{{distance}}" name="distance" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
  4036. </fieldset> \
  4037. </div> \
  4038. '
  4039. },
  4040. events: {
  4041. 'click .js-remove-filter': 'onRemoveFilter',
  4042. 'click .js-add-filter': 'onAddFilterShow',
  4043. 'submit form.js-edit': 'onTermFiltersUpdate',
  4044. 'submit form.js-add': 'onAddFilter'
  4045. },
  4046. initialize: function() {
  4047. this.el = $(this.el);
  4048. _.bindAll(this, 'render');
  4049. this.model.fields.bind('all', this.render);
  4050. this.model.queryState.bind('change', this.render);
  4051. this.model.queryState.bind('change:filters:new-blank', this.render);
  4052. this.render();
  4053. },
  4054. render: function() {
  4055. var self = this;
  4056. var tmplData = $.extend(true, {}, this.model.queryState.toJSON());
  4057. // we will use idx in list as there id ...
  4058. tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
  4059. filter.id = idx;
  4060. return filter;
  4061. });
  4062. tmplData.fields = this.model.fields.toJSON();
  4063. tmplData.filterRender = function() {
  4064. return Mustache.render(self.filterTemplates[this.type], this);
  4065. };
  4066. var out = Mustache.render(this.template, tmplData);
  4067. this.el.html(out);
  4068. },
  4069. onAddFilterShow: function(e) {
  4070. e.preventDefault();
  4071. var $target = $(e.target);
  4072. $target.hide();
  4073. this.el.find('form.js-add').show();
  4074. },
  4075. onAddFilter: function(e) {
  4076. e.preventDefault();
  4077. var $target = $(e.target);
  4078. $target.hide();
  4079. var filterType = $target.find('select.filterType').val();
  4080. var field = $target.find('select.fields').val();
  4081. var fieldType = this.model.fields.find(function (e) { return e.get('id') === field }).get('type');
  4082. this.model.queryState.addFilter({type: filterType, field: field, fieldType: fieldType});
  4083. // trigger render explicitly as queryState change will not be triggered (as blank value for filter)
  4084. this.render();
  4085. },
  4086. onRemoveFilter: function(e) {
  4087. e.preventDefault();
  4088. var $target = $(e.target);
  4089. var filterId = $target.closest('.filter').attr('data-filter-id');
  4090. this.model.queryState.removeFilter(filterId);
  4091. },
  4092. onTermFiltersUpdate: function(e) {
  4093. var self = this;
  4094. e.preventDefault();
  4095. var filters = self.model.queryState.get('filters');
  4096. var $form = $(e.target);
  4097. _.each($form.find('input'), function(input) {
  4098. var $input = $(input);
  4099. var filterType = $input.attr('data-filter-type');
  4100. var fieldId = $input.attr('data-filter-field');
  4101. var filterIndex = parseInt($input.attr('data-filter-id'));
  4102. var name = $input.attr('name');
  4103. var value = $input.val();
  4104. switch (filterType) {
  4105. case 'term':
  4106. filters[filterIndex].term = value;
  4107. break;
  4108. case 'range':
  4109. filters[filterIndex][name] = value;
  4110. break;
  4111. case 'geo_distance':
  4112. if(name === 'distance') {
  4113. filters[filterIndex].distance = parseFloat(value);
  4114. }
  4115. else {
  4116. filters[filterIndex].point[name] = parseFloat(value);
  4117. }
  4118. break;
  4119. }
  4120. });
  4121. self.model.queryState.set({filters: filters});
  4122. self.model.queryState.trigger('change');
  4123. }
  4124. });
  4125. })(jQuery, recline.View);
  4126. /*jshint multistr:true */
  4127. this.recline = this.recline || {};
  4128. this.recline.View = this.recline.View || {};
  4129. (function($, my) {
  4130. my.Pager = Backbone.View.extend({
  4131. className: 'recline-pager',
  4132. template: ' \
  4133. <div class="pagination"> \
  4134. <ul> \
  4135. <li class="prev action-pagination-update"><a href="">&laquo;</a></li> \
  4136. <li class="active"><a><input name="from" type="text" value="{{from}}" /> &ndash; <input name="to" type="text" value="{{to}}" /> </a></li> \
  4137. <li class="next action-pagination-update"><a href="">&raquo;</a></li> \
  4138. </ul> \
  4139. </div> \
  4140. ',
  4141. events: {
  4142. 'click .action-pagination-update': 'onPaginationUpdate',
  4143. 'change input': 'onFormSubmit'
  4144. },
  4145. initialize: function() {
  4146. _.bindAll(this, 'render');
  4147. this.el = $(this.el);
  4148. this.model.bind('change', this.render);
  4149. this.render();
  4150. },
  4151. onFormSubmit: function(e) {
  4152. e.preventDefault();
  4153. var newFrom = parseInt(this.el.find('input[name="from"]').val());
  4154. var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom;
  4155. this.model.set({size: newSize, from: newFrom});
  4156. },
  4157. onPaginationUpdate: function(e) {
  4158. e.preventDefault();
  4159. var $el = $(e.target);
  4160. var newFrom = 0;
  4161. if ($el.parent().hasClass('prev')) {
  4162. newFrom = this.model.get('from') - Math.max(0, this.model.get('size'));
  4163. } else {
  4164. newFrom = this.model.get('from') + this.model.get('size');
  4165. }
  4166. this.model.set({from: newFrom});
  4167. },
  4168. render: function() {
  4169. var tmplData = this.model.toJSON();
  4170. tmplData.to = this.model.get('from') + this.model.get('size');
  4171. var templated = Mustache.render(this.template, tmplData);
  4172. this.el.html(templated);
  4173. }
  4174. });
  4175. })(jQuery, recline.View);
  4176. /*jshint multistr:true */
  4177. this.recline = this.recline || {};
  4178. this.recline.View = this.recline.View || {};
  4179. (function($, my) {
  4180. my.QueryEditor = Backbone.View.extend({
  4181. className: 'recline-query-editor',
  4182. template: ' \
  4183. <form action="" method="GET" class="form-inline"> \
  4184. <div class="input-prepend text-query"> \
  4185. <span class="add-on"><i class="icon-search"></i></span> \
  4186. <input type="text" name="q" value="{{q}}" class="span2" placeholder="Search data ..." class="search-query" /> \
  4187. </div> \
  4188. <button type="submit" class="btn">Go &raquo;</button> \
  4189. </form> \
  4190. ',
  4191. events: {
  4192. 'submit form': 'onFormSubmit'
  4193. },
  4194. initialize: function() {
  4195. _.bindAll(this, 'render');
  4196. this.el = $(this.el);
  4197. this.model.bind('change', this.render);
  4198. this.render();
  4199. },
  4200. onFormSubmit: function(e) {
  4201. e.preventDefault();
  4202. var query = this.el.find('.text-query input').val();
  4203. this.model.set({q: query});
  4204. },
  4205. render: function() {
  4206. var tmplData = this.model.toJSON();
  4207. var templated = Mustache.render(this.template, tmplData);
  4208. this.el.html(templated);
  4209. }
  4210. });
  4211. })(jQuery, recline.View);