diff --git a/dist/recline.css b/dist/recline.css new file mode 100644 index 00000000..bde27901 --- /dev/null +++ b/dist/recline.css @@ -0,0 +1,693 @@ +.recline-graph .graph { + height: 500px; +} + +.recline-graph .legend table { + width: auto; + margin-bottom: 0; +} + +.recline-graph .legend td { + padding: 5px; + line-height: 13px; +} + +.recline-graph .graph .alert { + width: 450px; + margin: auto; +} + +/********************************************************** + * (Data) Grid + *********************************************************/ + +table.recline-grid { + table-layout: fixed; + width: 100%; +} + +.recline-grid .btn-group .dropdown-toggle { + padding: 1px 3px; + line-height: auto; +} + +.recline-grid td, .recline-grid th { + border-left: 1px solid #ccc; + padding: 3px 4px; + text-align: left; + word-wrap: break-word; + white-space: normal; +} + +.recline-grid td { + vertical-align: top; +} + +.recline-grid tr td:first-child, .recline-grid tr th:first-child { + width: 20px; +} + +.recline-grid tbody tr:last-child { + border-bottom: 1px solid #ccc; +} + +.recline-grid tbody td:last-child { + border-right: 1px solid #ccc; +} + +/* direct borrowing from twitter buttons */ +.recline-grid th, +.transform-column-view .expression-preview-table-wrapper th +{ + background-color: #e6e6e6; + background-repeat: no-repeat; + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6)); + background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6); + background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0); + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); + color: #333; + border: 1px solid #ccc; + border-bottom-color: #bbb; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -webkit-transition: 0.1s linear all; + -moz-transition: 0.1s linear all; + -ms-transition: 0.1s linear all; + -o-transition: 0.1s linear all; + transition: 0.1s linear all; +} + +/********************************************************** + * Fixed Header - http://www.imaputz.com/cssStuff/bigFourVersion.html + *********************************************************/ + +div.table-container { + overflow: auto; +} + +/* Reset overflow value to hidden for all non-IE browsers. */ +html>body div.table-container { + overflow: hidden; +} + +thead.fixed-header tr { + overflow-x: hidden; +} + +/* set table header to a fixed position. WinIE 6.x only */ +/* In WinIE 6.x, any element with a position property set to relative and is a child of */ +/* an element that has an overflow property set, the relative value translates into fixed. */ +/* Ex: parent element DIV with a class of table-container has an overflow property set to auto */ +thead.fixed-header tr { + position: relative +} + +/* set THEAD element to have block level attributes. All other non-IE browsers */ +/* this enables overflow to work on TBODY element. All other non-IE, non-Mozilla browsers */ +html>body thead.fixed-header tr { + display: block +} + +/* define the table content to be scrollable */ +/* set TBODY element to have block level attributes. All other non-IE browsers */ +/* this enables overflow to work on TBODY element. All other non-IE, non-Mozilla browsers */ +/* induced side effect is that child TDs no longer accept width: auto */ +tbody.scroll-content { + display: block; + max-height: 500px; + overflow: auto; +} + +/********************************************************** + * Data Table Menus + *********************************************************/ + +.column-header-menu, a.root-header-menu { + float: right; +} + +div.data-table-cell-content { + line-height: 1.2; + color: #222; + position: relative; +} + +div.data-table-cell-content-numeric { + text-align: right; +} + +a.data-table-cell-edit { + position: absolute; + top: 0; + right: 0; + display: block; + width: 25px; + height: 16px; + text-decoration: none; + background-image: url(images/edit-map.png); + background-repeat: no-repeat; + visibility: hidden; +} + +a.data-table-cell-edit:hover { + background-position: -25px 0px; +} + +.recline-grid td:hover .data-table-cell-edit { + visibility: visible; +} + +div.data-table-cell-content-numeric > a.data-table-cell-edit { + left: 0px; + right: auto; +} + +.data-table-value-nonstring { + color: #282; +} + +.data-table-error { + color: red; +} + +.data-table-cell-editor-editor { + overflow: hidden; + display: block; + width: 98%; + height: 3em; + font-family: monospace; + margin: 3px 0; +} + +.data-table-cell-copypaste-editor { + overflow: hidden; + display: block; + width: 98%; + height: 10em; + font-family: monospace; + margin: 3px 0; +} + +.data-table-cell-editor-action { + float: left; + vertical-align: bottom; + text-align: center; +} + +.data-table-cell-editor-key { + font-size: 0.8em; + color: #999; +} + + +/********************************************************** + * Transform Dialog + *********************************************************/ + +textarea.expression-preview-code { + font-family: monospace; + height: 5em; + vertical-align: top; +} + +.expression-preview-parsing-status { + color: #999; +} + +.expression-preview-parsing-status.error { + color: red; +} + +#expression-preview-tabs-preview, +#expression-preview-tabs-help, +#expression-preview-tabs-history, +#expression-preview-tabs-starred { + padding: 5px; + overflow: hidden; +} + +#expression-preview-tabs-preview > div, +#expression-preview-tabs-help > div, +#expression-preview-tabs-history > div, +#expression-preview-tabs-starred { + height: 200px; + overflow: auto; +} + +#expression-preview-tabs-preview td, #expression-preview-tabs-preview th, +#expression-preview-tabs-help td, #expression-preview-tabs-help th, +#expression-preview-tabs-history td, #expression-preview-tabs-history th, +#expression-preview-tabs-starred td, #expression-preview-tabs-starred th { + padding: 5px; +} + +.expression-preview-table-wrapper { + padding: 7px; +} + +.expression-preview-container td { + padding: 2px 5px; + border-top: 1px solid #ccc; +} + +td.expression-preview-heading { + border-top: none; + background: #ddd; + font-weight: bold; +} + +td.expression-preview-value { + max-width: 250px !important; + overflow-x: hidden; +} + +.expression-preview-special-value { + color: #aaa; +} + +.expression-preview-help-container h3 { + margin-top: 15px; + margin-bottom: 7px; + border-bottom: 1px solid #999; +} + +.expression-preview-doc-item-title { + font-weight: bold; + text-align: right; +} + +.expression-preview-doc-item-params { +} + +.expression-preview-doc-item-returns { +} + +.expression-preview-doc-item-desc { + color: #666; +} + + +/********************************************************** + * Read-only mode + *********************************************************/ + +.recline-read-only .recline-grid.no-hidden tr td:first-child, +.recline-read-only .recline-grid.no-hidden tr th:first-child +{ + display: none; +} + +.recline-read-only .recline-grid .write-op, +.recline-read-only .recline-grid a.data-table-cell-edit +{ + display: none; +} + +.recline-read-only a.row-header-menu { + display: none; +} + +.recline-map .map { + height: 500px; +} + +/********************************************************** + * Editor + *********************************************************/ + +.recline-map .editor { + float: right; + width: 200px; + padding-left: 0px; + margin-left: 10px; +} + +.recline-map .editor form { + padding-left: 4px; +} + +.recline-map .editor select { + width: 100%; +} + +.recline-map .editor .editor-options { + margin-top: 10px; + border-top: 1px solid gray; + padding: 5px 0; +} +.recline-data-explorer .data-view-container { + display: block; + margin-right: 225px; +} + +.recline-data-explorer .data-view-sidebar { + float: right; + margin-left: 8px; + width: 220px; +} + +.recline-data-explorer .header .navigation { + margin-bottom: 8px; +} + +.recline-data-explorer .header .navigation, +.recline-data-explorer .header .pagination, +.recline-data-explorer .header .pagination form +{ + display: inline; +} + +.recline-data-explorer .header .navigation { + float: left; +} + +.recline-data-explorer .header .menu-right { + float: right; + margin-left: 5px; + padding-left: 5px; + border-left: solid 2px #ddd; +} + +.header .recline-results-info { + line-height: 28px; + margin-left: 20px; + float: left; +} + +/********************************************************** + * Query Editor + *********************************************************/ + +.header .recline-query-editor { + float: right; + height: 30px; +} + +.header .input-prepend { + margin-bottom: auto; +} + +.header .add-on { + float: left; +} + +/* needed for Chrome but not FF */ +.header .add-on { + margin-left: -27px; +} + +/* needed for FF but not chrome */ +.header .input-prepend { + vertical-align: top; +} + +.header .recline-query-editor form button { + vertical-align: top; +} + +/********************************************************** + * Pager + *********************************************************/ + +.header .recline-pager { + float: left; + margin: auto; + display: block; + margin-left: 20px; +} + +.header .recline-pager .pagination input { + width: 30px; + height: 18px; + padding: 2px 4px; + margin: 0; + margin-top: -4px; +} + +.header .recline-pager .pagination a { + line-height: 26px; + padding: 0 6px; +} + +/********************************************************** + * Filter Editor + *********************************************************/ + +.recline-filter-editor { + padding: 8px; +} + +.recline-filter-editor .filter-term a { + font-size: 18px; +} + +.recline-filter-editor input, +.recline-filter-editor select +{ + width: 175px; +} + + +/********************************************************** + * Fields Widget + *********************************************************/ + +.recline-fields-view .fields-list { + padding: 0; +} + +.recline-fields-view .fields-list .accordion-heading, +.recline-fields-view .fields-list h3 +{ + margin: 3px 0 3px 5px; +} + +.recline-fields-view .fields-list .accordion-heading a, +.recline-fields-view .fields-list .accordion-heading h4 { + display: inline; +} + +.recline-fields-view .fields-list .accordion-heading a { + padding: 0; +} + +.recline-fields-view .fields-list .accordion-heading h4 { + word-wrap: break-word +} + +.recline-fields-view .clear { + clear: both; +} + +.recline-fields-view .facet-items { + list-style-type: none; + margin-left: 0; +} + +.recline-fields-view .facet-item .term { + font-weight: bold; +} + +.recline-fields-view .facet-item .count { +} + +/********************************************************** + * Notifications + *********************************************************/ + +.recline-data-explorer .notification-loader { + width: 18px; + margin-left: 5px; + background: url(images/small-spinner.gif) no-repeat; + display: inline-block; +} + +.recline-data-explorer .alert-loader { + position: absolute; + width: 200px; + left: 50%; + margin-left: -100px; + z-index: 10000; + padding: 40px 0px 40px 0px; + margin-top: -10px; + text-align: center; + font-size: 16px; + font-weight: bold; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + border-radius: 0px; + border-top: none; +} + +/* +IMPORTANT: +In order to preserve the uniform grid appearance, all cell styles need to have padding, margin and border sizes. +No built-in (selected, editable, highlight, flashing, invalid, loading, :focus) or user-specified CSS +classes should alter those! +*/ + +.recline-slickgrid .slick-header-columns .slick-header-column { + background-color: #e6e6e6; + background-repeat: no-repeat; + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6)); + background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6); + background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0); + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); + color: #333; + font-weight: bold; + border-right: 1px solid #ccc; + border-top: 1px solid #ccc; + border-bottom: 1px solid #bbb; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.recline-slickgrid .slick-header-column:hover, .slick-header-column-active { +} + +.recline-slickgrid .slick-headerrow { + background: #fafafa; +} + +.recline-slickgrid .slick-headerrow-column { + background: #fafafa; + border-bottom: 0; + height: 100%; +} + +.recline-slickgrid .slick-row.ui-state-active { + background: #F5F7D7; +} + +.recline-slickgrid .slick-row { + position: absolute; + background: white; + border: 0px; + line-height: 20px; +} + +.recline-slickgrid .slick-row.selected { + z-index: 10; + background: #DFE8F6; +} + +.recline-slickgrid .slick-cell { + padding-left: 4px; + padding-right: 4px; +} + +.recline-slickgrid .slick-group { + border-bottom: 2px solid silver; +} + +.recline-slickgrid .slick-group-toggle { + width: 9px; + height: 9px; + margin-right: 5px; +} + +.recline-slickgrid .slick-group-toggle.expanded { + background: url(../images/collapse.gif) no-repeat center center; +} + +.recline-slickgrid .slick-group-toggle.collapsed { + background: url(../images/expand.gif) no-repeat center center; +} + +.recline-slickgrid .slick-group-totals { + color: gray; + background: white; +} + +.recline-slickgrid .slick-cell.selected { + background-color: beige; +} + +.recline-slickgrid .slick-cell.active { + border-color: gray; + border-style: solid; +} + +.recline-slickgrid .slick-sortable-placeholder { + background: silver !important; +} + +.recline-slickgrid .slick-row[row$="1"], .slick-row[row$="3"], .slick-row[row$="5"], .slick-row[row$="7"], .slick-row[row$="9"] { + background: #fafafa; +} + +.recline-slickgrid .slick-row.ui-state-active { + background: #F5F7D7; +} + +.recline-slickgrid .slick-row.loading { + opacity: 0.5; + filter: alpha(opacity = 50); +} + +.recline-slickgrid .slick-cell.invalid { + border-color: red; +} + +.recline-slickgrid .slick-row .slick-cell:first-child, +.recline-slickgrid .slick-header { + border-left: 1px solid #ccc; +} + +/* add one pixel extra as added one pixel to left border of header */ +.recline-slickgrid .slick-row .slick-cell { + margin-right: -1px; +} + +/* Slick grid context menu (not part of the recline-slickgrid div) */ +.slick-contextmenu { + border-radius: 5px +} + +.slick-contextmenu li { + clear: both; + height: 24px; + cursor: pointer; +} + +.slick-contextmenu .divider { + cursor: default; +} + +.slick-contextmenu > li:hover { + background-color: #0088cc; +} + +.slick-contextmenu .divider:hover { + background-color: #E5E5E5; +} + +.slick-contextmenu li:hover > label { + color: white; +} + +.slick-contextmenu input { + float: left; + margin-left: 15px; + margin-top: 5px; +} + +.slick-contextmenu label { + float: left; + margin-right: 15px; + margin-left: 5px; + margin-top: 3px; + color: #555; + cursor: pointer; +} + diff --git a/dist/recline.js b/dist/recline.js index 667216b2..1cedf311 100644 --- a/dist/recline.js +++ b/dist/recline.js @@ -179,6 +179,31 @@ my.Dataset = Backbone.Model.extend({ return data; }, + // Get a summary for each field in the form of a `Facet`. + // + // @return null as this is async function. Provides deferred/promise interface. + getFieldsSummary: function() { + var self = this; + var query = new my.Query(); + query.set({size: 0}); + this.fields.each(function(field) { + query.addFacet(field.id); + }); + var dfd = $.Deferred(); + this.backend.query(this, query.toJSON()).done(function(queryResult) { + if (queryResult.facets) { + _.each(queryResult.facets, function(facetResult, facetId) { + facetResult.id = facetId; + var facet = new my.Facet(facetResult); + // TODO: probably want replace rather than reset (i.e. just replace the facet with this id) + self.fields.get(facetId).facets.reset(facet); + }); + } + dfd.resolve(queryResult); + }); + return dfd.promise(); + }, + // ### _backendFromString(backendString) // // See backend argument to initialize for details @@ -359,6 +384,7 @@ my.Field = Backbone.Model.extend({ if (!this.renderer) { this.renderer = this.defaultRenderers[this.get('type')]; } + this.facets = new my.FacetList(); }, defaultRenderers: { object: function(val, field, doc) { @@ -467,7 +493,7 @@ my.Query = Backbone.Model.extend({ addTermFilter: function(fieldId, value) { var filters = this.get('filters'); var filter = { term: {} }; - filter.term[fieldId] = value; + filter.term[fieldId] = value || ''; filters.push(filter); this.set({filters: filters}); // change does not seem to be triggered automatically @@ -612,44 +638,10 @@ this.recline.View = this.recline.View || {}; // NB: should *not* provide an el argument to the view but must let the view // generate the element itself (you can then append view.el to the DOM. my.Graph = Backbone.View.extend({ - tagName: "div", className: "recline-graph", template: ' \ -
\ -
\ -
\ - \ -
\ - \ -
\ - \ -
\ - \ -
\ -
\ -
\ -
\ -
\ - \ -
\ - \ -
\ -
\
\
\

Hey there!

\ @@ -659,26 +651,6 @@ my.Graph = Backbone.View.extend({
\
\ ', - templateSeriesEditor: ' \ -
\ - \ -
\ - \ -
\ -
\ - ', - - events: { - 'change form select': 'onEditorSubmit', - 'click .editor-add': '_onAddSeries', - 'click .action-remove-series': 'removeSeries' - }, initialize: function(options) { var self = this; @@ -705,6 +677,15 @@ my.Graph = Backbone.View.extend({ options.state ); this.state = new recline.Model.ObjectState(stateData); + this.editor = new my.GraphControls({ + model: this.model, + state: this.state.toJSON() + }); + this.editor.state.bind('change', function() { + self.state.set(self.editor.state.toJSON()); + self.redraw(); + }); + this.elSidebar = this.editor.el; this.render(); }, @@ -714,56 +695,9 @@ my.Graph = Backbone.View.extend({ var htmls = Mustache.render(this.template, tmplData); $(this.el).html(htmls); this.$graph = this.el.find('.panel.graph'); - - // set up editor from state - if (this.state.get('graphType')) { - this._selectOption('.editor-type', this.state.get('graphType')); - } - if (this.state.get('group')) { - this._selectOption('.editor-group', this.state.get('group')); - } - // ensure at least one series box shows up - var tmpSeries = [""]; - if (this.state.get('series').length > 0) { - tmpSeries = this.state.get('series'); - } - _.each(tmpSeries, function(series, idx) { - self.addSeries(idx); - self._selectOption('.editor-series.js-series-' + idx, series); - }); return this; }, - // Private: Helper function to select an option from a select list - // - _selectOption: function(id,value){ - var options = this.el.find(id + ' select > option'); - if (options) { - options.each(function(opt){ - if (this.value == value) { - $(this).attr('selected','selected'); - return false; - } - }); - } - }, - - onEditorSubmit: function(e) { - var select = this.el.find('.editor-group select'); - var $editor = this; - var $series = this.el.find('.editor-series select'); - var series = $series.map(function () { - return $(this).val(); - }); - var updatedState = { - series: $.makeArray(series), - group: this.el.find('.editor-group select').val(), - graphType: this.el.find('.editor-type select').val() - }; - this.state.set(updatedState); - this.redraw(); - }, - redraw: function() { // There appear to be issues generating a Flot graph if either: @@ -778,6 +712,8 @@ my.Graph = Backbone.View.extend({ } // check we have something to plot if (this.state.get('group') && this.state.get('series')) { + // 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 + this.$graph.width(this.el.width() - 20); var series = this.createSeries(); var options = this.getGraphOptions(this.state.attributes.graphType); this.plot = $.plot(this.$graph, series, options); @@ -941,7 +877,10 @@ my.Graph = Backbone.View.extend({ var yfield = self.model.fields.get(field); var y = doc.getFieldValue(yfield); if (typeof x === 'string') { - x = index; + x = parseFloat(x); + if (isNaN(x)) { + x = index; + } } // horizontal bar chart if (self.state.attributes.graphType == 'bars') { @@ -953,6 +892,128 @@ my.Graph = Backbone.View.extend({ series.push({data: points, label: field}); }); return series; + } +}); + +my.GraphControls = Backbone.View.extend({ + className: "editor", + template: ' \ +
\ +
\ +
\ + \ +
\ + \ +
\ + \ +
\ + \ +
\ +
\ +
\ +
\ +
\ + \ +
\ + \ +
\ +
\ +', + templateSeriesEditor: ' \ +
\ + \ +
\ + \ +
\ +
\ + ', + events: { + 'change form select': 'onEditorSubmit', + 'click .editor-add': '_onAddSeries', + 'click .action-remove-series': 'removeSeries' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.fields.bind('reset', this.render); + this.model.fields.bind('add', this.render); + this.state = new recline.Model.ObjectState(options.state); + this.render(); + }, + + render: function() { + var self = this; + var tmplData = this.model.toTemplateJSON(); + var htmls = Mustache.render(this.template, tmplData); + this.el.html(htmls); + + // set up editor from state + if (this.state.get('graphType')) { + this._selectOption('.editor-type', this.state.get('graphType')); + } + if (this.state.get('group')) { + this._selectOption('.editor-group', this.state.get('group')); + } + // ensure at least one series box shows up + var tmpSeries = [""]; + if (this.state.get('series').length > 0) { + tmpSeries = this.state.get('series'); + } + _.each(tmpSeries, function(series, idx) { + self.addSeries(idx); + self._selectOption('.editor-series.js-series-' + idx, series); + }); + return this; + }, + + // Private: Helper function to select an option from a select list + // + _selectOption: function(id,value){ + var options = this.el.find(id + ' select > option'); + if (options) { + options.each(function(opt){ + if (this.value == value) { + $(this).attr('selected','selected'); + return false; + } + }); + } + }, + + onEditorSubmit: function(e) { + var select = this.el.find('.editor-group select'); + var $editor = this; + var $series = this.el.find('.editor-series select'); + var series = $series.map(function () { + return $(this).val(); + }); + var updatedState = { + series: $.makeArray(series), + group: this.el.find('.editor-group select').val(), + graphType: this.el.find('.editor-type select').val() + }; + this.state.set(updatedState); }, // Public: Adds a new empty series select box to the editor. @@ -1386,82 +1447,18 @@ this.recline.View = this.recline.View || {}; // } // my.Map = Backbone.View.extend({ - tagName: 'div', className: 'recline-map', template: ' \ -
\ -
\ -
\ -
\ - \ - \ -
\ -
\ - \ -
\ - \ -
\ - \ -
\ - \ -
\ -
\ - \ -
\ -
\ - \ -
\ -
\ - \ -
\ - \ -
\ - \ - \ -
\ -
\ +
\ ', // These are the default (case-insensitive) names of field that are used if found. // If not found, the user will need to define the fields via the editor. latitudeFieldNames: ['lat','latitude'], longitudeFieldNames: ['lon','longitude'], - geometryFieldNames: ['geom','the_geom','geometry','spatial','location'], - - // Define here events for UI elements - events: { - 'click .editor-update-map': 'onEditorSubmit', - 'change .editor-field-type': 'onFieldTypeChange', - 'change #editor-auto-zoom': 'onAutoZoomChange' - }, + geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location'], initialize: function(options) { var self = this; @@ -1469,10 +1466,6 @@ my.Map = Backbone.View.extend({ // Listen to changes in the fields this.model.fields.bind('change', function() { - self._setupGeometryField(); - }); - this.model.fields.bind('add', this.render); - this.model.fields.bind('reset', function(){ self._setupGeometryField() self.render() }); @@ -1491,7 +1484,7 @@ my.Map = Backbone.View.extend({ // to display properly if (self.map){ self.map.invalidateSize(); - if (self._zoomPending && self.autoZoom) { + if (self._zoomPending && self.state.get('autoZoom')) { self._zoomToFeatures(); self._zoomPending = false; } @@ -1505,39 +1498,36 @@ my.Map = Backbone.View.extend({ var stateData = _.extend({ geomField: null, lonField: null, - latField: null + latField: null, + autoZoom: true }, options.state ); this.state = new recline.Model.ObjectState(stateData); + this.menu = new my.MapMenu({ + model: this.model, + state: this.state.toJSON() + }); + this.menu.state.bind('change', function() { + self.state.set(self.menu.state.toJSON()); + self.redraw(); + }); + this.elSidebar = this.menu.el; - this.autoZoom = true; this.mapReady = false; this.render(); + this.redraw(); }, // ### Public: Adds the necessary elements to the page. // // Also sets up the editor fields and the map if necessary. render: function() { - var self = this; htmls = Mustache.render(this.template, this.model.toTemplateJSON()); - $(this.el).html(htmls); this.$map = this.el.find('.panel.map'); - - if (this.geomReady && this.model.fields.length){ - if (this.state.get('geomField')){ - this._selectOption('editor-geom-field',this.state.get('geomField')); - $('#editor-field-type-geom').attr('checked','checked').change(); - } else{ - this._selectOption('editor-lon-field',this.state.get('lonField')); - this._selectOption('editor-lat-field',this.state.get('latField')); - $('#editor-field-type-latlon').attr('checked','checked').change(); - } - } return this; }, @@ -1553,14 +1543,14 @@ my.Map = Backbone.View.extend({ var self = this; action = action || 'refresh'; // try to set things up if not already - if (!self.geomReady){ + if (!self._geomReady()){ self._setupGeometryField(); } if (!self.mapReady){ self._setupMap(); } - if (this.geomReady && this.mapReady){ + if (this._geomReady() && this.mapReady){ if (action == 'reset' || action == 'refresh'){ this.features.clearLayers(); this._add(this.model.currentRecords.models); @@ -1569,7 +1559,7 @@ my.Map = Backbone.View.extend({ } else if (action == 'remove' && doc){ this._remove(doc); } - if (this.autoZoom){ + if (this.state.get('autoZoom')){ if (this.visible){ this._zoomToFeatures(); } else { @@ -1579,51 +1569,8 @@ my.Map = Backbone.View.extend({ } }, - // - // UI Event handlers - // - - // Public: Update map with user options - // - // Right now the only configurable option is what field(s) contains the - // location information. - // - onEditorSubmit: function(e){ - e.preventDefault(); - if ($('#editor-field-type-geom').attr('checked')){ - this.state.set({ - geomField: $('.editor-geom-field > select > option:selected').val(), - lonField: null, - latField: null - }); - } else { - this.state.set({ - geomField: null, - lonField: $('.editor-lon-field > select > option:selected').val(), - latField: $('.editor-lat-field > select > option:selected').val() - }); - } - this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); - this.redraw(); - - return false; - }, - - // Public: Shows the relevant select lists depending on the location field - // type selected. - // - onFieldTypeChange: function(e){ - if (e.target.value == 'geom'){ - $('.editor-field-type-geom').show(); - $('.editor-field-type-latlon').hide(); - } else { - $('.editor-field-type-geom').hide(); - $('.editor-field-type-latlon').show(); - } - }, - - onAutoZoomChange: function(e){ - this.autoZoom = !this.autoZoom; + _geomReady: function() { + return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); }, // Private: Add one or n features to the map @@ -1703,7 +1650,7 @@ my.Map = Backbone.View.extend({ // Private: Return a GeoJSON geomtry extracted from the record fields // _getGeometryFromRecord: function(doc){ - if (this.geomReady){ + if (this._geomReady()){ if (this.state.get('geomField')){ var value = doc.get(this.state.get('geomField')); if (typeof(value) === 'string'){ @@ -1742,16 +1689,14 @@ my.Map = Backbone.View.extend({ // // If not found, the user can define them via the UI form. _setupGeometryField: function(){ - var geomField, latField, lonField; - this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); // should not overwrite if we have already set this (e.g. explicitly via state) - if (!this.geomReady) { + if (!this._geomReady()) { this.state.set({ geomField: this._checkField(this.geometryFieldNames), latField: this._checkField(this.latitudeFieldNames), lonField: this._checkField(this.longitudeFieldNames) }); - this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + this.menu.state.set(this.state.toJSON()); } }, @@ -1789,7 +1734,6 @@ my.Map = Backbone.View.extend({ // on [OpenStreetMap](http://openstreetmap.org). // _setupMap: function(){ - this.map = new L.Map(this.$map.get(0)); var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png"; @@ -1845,8 +1789,645 @@ my.Map = Backbone.View.extend({ }); } } +}); - }); +my.MapMenu = Backbone.View.extend({ + className: 'editor', + + template: ' \ +
\ +
\ +
\ + \ + \ +
\ +
\ + \ +
\ + \ +
\ + \ +
\ + \ +
\ +
\ + \ +
\ +
\ + \ +
\ +
\ + \ +
\ + \ + \ +
\ +', + + // Define here events for UI elements + events: { + 'click .editor-update-map': 'onEditorSubmit', + 'change .editor-field-type': 'onFieldTypeChange', + 'change #editor-auto-zoom': 'onAutoZoomChange' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.fields.bind('change', this.render); + this.state = new recline.Model.ObjectState(options.state); + this.state.bind('change', this.render); + this.render(); + }, + + // ### Public: Adds the necessary elements to the page. + // + // Also sets up the editor fields and the map if necessary. + render: function() { + var self = this; + htmls = Mustache.render(this.template, this.model.toTemplateJSON()); + $(this.el).html(htmls); + + if (this._geomReady() && this.model.fields.length){ + if (this.state.get('geomField')){ + this._selectOption('editor-geom-field',this.state.get('geomField')); + $('#editor-field-type-geom').attr('checked','checked').change(); + } else{ + this._selectOption('editor-lon-field',this.state.get('lonField')); + this._selectOption('editor-lat-field',this.state.get('latField')); + $('#editor-field-type-latlon').attr('checked','checked').change(); + } + } + return this; + }, + + _geomReady: function() { + return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + }, + + // ## UI Event handlers + // + + // Public: Update map with user options + // + // Right now the only configurable option is what field(s) contains the + // location information. + // + onEditorSubmit: function(e){ + e.preventDefault(); + if (this.el.find('#editor-field-type-geom').attr('checked')){ + this.state.set({ + geomField: this.el.find('.editor-geom-field > select > option:selected').val(), + lonField: null, + latField: null + }); + } else { + this.state.set({ + geomField: null, + lonField: this.el.find('.editor-lon-field > select > option:selected').val(), + latField: this.el.find('.editor-lat-field > select > option:selected').val() + }); + } + return false; + }, + + // Public: Shows the relevant select lists depending on the location field + // type selected. + // + onFieldTypeChange: function(e){ + if (e.target.value == 'geom'){ + this.el.find('.editor-field-type-geom').show(); + this.el.find('.editor-field-type-latlon').hide(); + } else { + this.el.find('.editor-field-type-geom').hide(); + this.el.find('.editor-field-type-latlon').show(); + } + }, + + onAutoZoomChange: function(e){ + this.state.set({autoZoom: !this.state.get('autoZoom')}); + }, + + // Private: Helper function to select an option from a select list + // + _selectOption: function(id,value){ + var options = this.el.find('.' + id + ' > select > option'); + if (options){ + options.each(function(opt){ + if (this.value == value) { + $(this).attr('selected','selected'); + return false; + } + }); + } + } +}); + +})(jQuery, recline.View); + +/*jshint multistr:true */ + +// Standard JS module setup +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { +// ## MultiView +// +// Manage multiple views together along with query editor etc. Usage: +// +//
+// var myExplorer = new model.recline.MultiView({
+//   model: {{recline.Model.Dataset instance}}
+//   el: {{an existing dom element}}
+//   views: {{dataset views}}
+//   state: {{state configuration -- see below}}
+// });
+// 
+// +// ### Parameters +// +// **model**: (required) recline.model.Dataset instance. +// +// **el**: (required) DOM element to bind to. NB: the element already +// being in the DOM is important for rendering of some subviews (e.g. +// Graph). +// +// **views**: (optional) the dataset views (Grid, Graph etc) for +// MultiView to show. This is an array of view hashes. If not provided +// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id +// and labels!). +// +//
+// var views = [
+//   {
+//     id: 'grid', // used for routing
+//     label: 'Grid', // used for view switcher
+//     view: new recline.View.Grid({
+//       model: dataset
+//     })
+//   },
+//   {
+//     id: 'graph',
+//     label: 'Graph',
+//     view: new recline.View.Graph({
+//       model: dataset
+//     })
+//   }
+// ];
+// 
+// +// **state**: standard state config for this view. This state is slightly +// special as it includes config of many of the subviews. +// +//
+// state = {
+//     query: {dataset query state - see dataset.queryState object}
+//     view-{id1}: {view-state for this view}
+//     view-{id2}: {view-state for }
+//     ...
+//     // Explorer
+//     currentView: id of current view (defaults to first view if not specified)
+//     readOnly: (default: false) run in read-only mode
+// }
+// 
+// +// Note that at present we do *not* serialize information about the actual set +// of views in use -- e.g. those specified by the views argument -- but instead +// expect either that the default views are fine or that the client to have +// initialized the MultiView with the relevant views themselves. +my.MultiView = Backbone.View.extend({ + template: ' \ +
\ +
\ + \ +
\ + \ +
\ + Results found {{docCount}} \ +
\ + \ +
\ +
\ +
\ +
\ +
\ +
\ + ', + events: { + 'click .menu-right a': '_onMenuClick', + 'click .navigation a': '_onSwitchView' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + this._setupState(options.state); + // Hash of 'page' views (i.e. those for whole page) keyed by page name + if (options.views) { + this.pageViews = options.views; + } else { + this.pageViews = [{ + id: 'grid', + label: 'Grid', + view: new my.Grid({ + model: this.model, + state: this.state.get('view-grid') + }), + }, { + id: 'graph', + label: 'Graph', + view: new my.Graph({ + model: this.model, + state: this.state.get('view-graph') + }), + }, { + id: 'map', + label: 'Map', + view: new my.Map({ + model: this.model, + state: this.state.get('view-map') + }), + }, { + id: 'timeline', + label: 'Timeline', + view: new my.Timeline({ + model: this.model, + state: this.state.get('view-timeline') + }), + }]; + } + // these must be called after pageViews are created + this.render(); + this._bindStateChanges(); + this._bindFlashNotifications(); + // now do updates based on state (need to come after render) + if (this.state.get('readOnly')) { + this.setReadOnly(); + } + if (this.state.get('currentView')) { + this.updateNav(this.state.get('currentView')); + } else { + this.updateNav(this.pageViews[0].id); + } + + this.model.bind('query:start', function() { + self.notify({loader: true, persist: true}); + }); + this.model.bind('query:done', function() { + self.clearNotifications(); + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + }); + this.model.bind('query:fail', function(error) { + self.clearNotifications(); + var msg = ''; + if (typeof(error) == 'string') { + msg = error; + } else if (typeof(error) == 'object') { + if (error.title) { + msg = error.title + ': '; + } + if (error.message) { + msg += error.message; + } + } else { + msg = 'There was an error querying the backend'; + } + self.notify({message: msg, category: 'error', persist: true}); + }); + + // retrieve basic data like fields etc + // note this.model and dataset returned are the same + this.model.fetch() + .done(function(dataset) { + self.model.query(self.state.get('query')); + }) + .fail(function(error) { + self.notify({message: error.message, category: 'error', persist: true}); + }); + }, + + setReadOnly: function() { + this.el.addClass('recline-read-only'); + }, + + render: function() { + var tmplData = this.model.toTemplateJSON(); + tmplData.views = this.pageViews; + var template = Mustache.render(this.template, tmplData); + $(this.el).html(template); + + // now create and append other views + var $dataViewContainer = this.el.find('.data-view-container'); + var $dataSidebar = this.el.find('.data-view-sidebar'); + + // the main views + _.each(this.pageViews, function(view, pageName) { + $dataViewContainer.append(view.view.el); + if (view.view.elSidebar) { + $dataSidebar.append(view.view.elSidebar); + } + }); + + var pager = new recline.View.Pager({ + model: this.model.queryState + }); + this.el.find('.recline-results-info').after(pager.el); + + var queryEditor = new recline.View.QueryEditor({ + model: this.model.queryState + }); + this.el.find('.query-editor-here').append(queryEditor.el); + + var filterEditor = new recline.View.FilterEditor({ + model: this.model + }); + this.$filterEditor = filterEditor.el; + $dataSidebar.append(filterEditor.el); + // are there actually any filters to show? + if (this.model.get('filters') && this.model.get('filters').length > 0) { + this.$filterEditor.show(); + } else { + this.$filterEditor.hide(); + } + + var fieldsView = new recline.View.Fields({ + model: this.model + }); + this.$fieldsView = fieldsView.el; + $dataSidebar.append(fieldsView.el); + }, + + updateNav: function(pageName) { + this.el.find('.navigation a').removeClass('active'); + var $el = this.el.find('.navigation a[data-view="' + pageName + '"]'); + $el.addClass('active'); + // show the specific page + _.each(this.pageViews, function(view, idx) { + if (view.id === pageName) { + view.view.el.show(); + if (view.view.elSidebar) { + view.view.elSidebar.show(); + } + view.view.trigger('view:show'); + } else { + view.view.el.hide(); + if (view.view.elSidebar) { + view.view.elSidebar.hide(); + } + view.view.trigger('view:hide'); + } + }); + }, + + _onMenuClick: function(e) { + e.preventDefault(); + var action = $(e.target).attr('data-action'); + if (action === 'filters') { + this.$filterEditor.toggle(); + } else if (action === 'fields') { + this.$fieldsView.toggle(); + } + }, + + _onSwitchView: function(e) { + e.preventDefault(); + var viewName = $(e.target).attr('data-view'); + this.updateNav(viewName); + this.state.set({currentView: viewName}); + }, + + // create a state object for this view and do the job of + // + // a) initializing it from both data passed in and other sources (e.g. hash url) + // + // b) ensure the state object is updated in responese to changes in subviews, query etc. + _setupState: function(initialState) { + var self = this; + // get data from the query string / hash url plus some defaults + var qs = my.parseHashQueryString(); + var query = qs.reclineQuery; + query = query ? JSON.parse(query) : self.model.queryState.toJSON(); + // backwards compatability (now named view-graph but was named graph) + var graphState = qs['view-graph'] || qs.graph; + graphState = graphState ? JSON.parse(graphState) : {}; + + // now get default data + hash url plus initial state and initial our state object with it + var stateData = _.extend({ + query: query, + 'view-graph': graphState, + backend: this.model.backend.__type__, + url: this.model.get('url'), + currentView: null, + readOnly: false + }, + initialState); + this.state = new recline.Model.ObjectState(stateData); + }, + + _bindStateChanges: function() { + var self = this; + // finally ensure we update our state object when state of sub-object changes so that state is always up to date + this.model.queryState.bind('change', function() { + self.state.set({query: self.model.queryState.toJSON()}); + }); + _.each(this.pageViews, function(pageView) { + if (pageView.view.state && pageView.view.state.bind) { + var update = {}; + update['view-' + pageView.id] = pageView.view.state.toJSON(); + self.state.set(update); + pageView.view.state.bind('change', function() { + var update = {}; + update['view-' + pageView.id] = pageView.view.state.toJSON(); + // had problems where change not being triggered for e.g. grid view so let's do it explicitly + self.state.set(update, {silent: true}); + self.state.trigger('change'); + }); + } + }); + }, + + _bindFlashNotifications: function() { + var self = this; + _.each(this.pageViews, function(pageView) { + pageView.view.bind('recline:flash', function(flash) { + self.notify(flash); + }); + }); + }, + + // ### notify + // + // Create a notification (a div.alert in div.alert-messsages) using provided + // flash object. Flash attributes (all are optional): + // + // * message: message to show. + // * category: warning (default), success, error + // * persist: if true alert is persistent, o/w hidden after 3s (default = false) + // * loader: if true show loading spinner + notify: function(flash) { + var tmplData = _.extend({ + message: 'Loading', + category: 'warning', + loader: false + }, + flash + ); + if (tmplData.loader) { + var _template = ' \ +
\ + {{message}} \ +   \ +
'; + } else { + var _template = ' \ +
× \ + {{message}} \ +
'; + } + var _templated = $(Mustache.render(_template, tmplData)); + _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); + if (!flash.persist) { + setTimeout(function() { + $(_templated).fadeOut(1000, function() { + $(this).remove(); + }); + }, 1000); + } + }, + + // ### clearNotifications + // + // Clear all existing notifications + clearNotifications: function() { + var $notifications = $('.recline-data-explorer .alert-messages .alert'); + $notifications.fadeOut(1500, function() { + $(this).remove(); + }); + } +}); + +// ### MultiView.restore +// +// Restore a MultiView instance from a serialized state including the associated dataset +my.MultiView.restore = function(state) { + var dataset = recline.Model.Dataset.restore(state); + var explorer = new my.MultiView({ + model: dataset, + state: state + }); + return explorer; +} + + +// ## Miscellaneous Utilities +var urlPathRegex = /^([^?]+)(\?.*)?/; + +// Parse the Hash section of a URL into path and query string +my.parseHashUrl = function(hashUrl) { + var parsed = urlPathRegex.exec(hashUrl); + if (parsed === null) { + return {}; + } else { + return { + path: parsed[1], + query: parsed[2] || '' + }; + } +}; + +// Parse a URL query string (?xyz=abc...) into a dictionary. +my.parseQueryString = function(q) { + if (!q) { + return {}; + } + var urlParams = {}, + e, d = function (s) { + return unescape(s.replace(/\+/g, " ")); + }, + r = /([^&=]+)=?([^&]*)/g; + + if (q && q.length && q[0] === '?') { + q = q.slice(1); + } + while (e = r.exec(q)) { + // TODO: have values be array as query string allow repetition of keys + urlParams[d(e[1])] = d(e[2]); + } + return urlParams; +}; + +// Parse the query string out of the URL hash +my.parseHashQueryString = function() { + q = my.parseHashUrl(window.location.hash).query; + return my.parseQueryString(q); +}; + +// Compse a Query String +my.composeQueryString = function(queryParams) { + var queryString = '?'; + var items = []; + $.each(queryParams, function(key, value) { + if (typeof(value) === 'object') { + value = JSON.stringify(value); + } + items.push(key + '=' + encodeURIComponent(value)); + }); + queryString += items.join('&'); + return queryString; +}; + +my.getNewHashForQueryString = function(queryParams) { + var queryPart = my.composeQueryString(queryParams); + if (window.location.hash) { + // slice(1) to remove # at start + return window.location.hash.split('?')[0].slice(1) + queryPart; + } else { + return queryPart; + } +}; + +my.setHashQueryString = function(queryParams) { + window.location.hash = my.getNewHashForQueryString(queryParams); +}; })(jQuery, recline.View); @@ -2262,6 +2843,14 @@ my.Timeline = Backbone.View.extend({ out.timeline.date.push(tlEntry); } }); + // if no entries create a placeholder entry to prevent Timeline crashing with error + if (out.timeline.date.length === 0) { + var tlEntry = { + "startDate": '2000,1,1', + "headline": 'No data to show!' + }; + out.timeline.date.push(tlEntry); + } return out; }, @@ -2462,460 +3051,6 @@ my.ColumnTransform = Backbone.View.extend({ })(jQuery, recline.View); /*jshint multistr:true */ -// Standard JS module setup -this.recline = this.recline || {}; -this.recline.View = this.recline.View || {}; - -(function($, my) { -// ## MultiView -// -// Manage multiple views together along with query editor etc. Usage: -// -//
-// var myExplorer = new model.recline.MultiView({
-//   model: {{recline.Model.Dataset instance}}
-//   el: {{an existing dom element}}
-//   views: {{dataset views}}
-//   state: {{state configuration -- see below}}
-// });
-// 
-// -// ### Parameters -// -// **model**: (required) recline.model.Dataset instance. -// -// **el**: (required) DOM element to bind to. NB: the element already -// being in the DOM is important for rendering of some subviews (e.g. -// Graph). -// -// **views**: (optional) the dataset views (Grid, Graph etc) for -// MultiView to show. This is an array of view hashes. If not provided -// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id -// and labels!). -// -//
-// var views = [
-//   {
-//     id: 'grid', // used for routing
-//     label: 'Grid', // used for view switcher
-//     view: new recline.View.Grid({
-//       model: dataset
-//     })
-//   },
-//   {
-//     id: 'graph',
-//     label: 'Graph',
-//     view: new recline.View.Graph({
-//       model: dataset
-//     })
-//   }
-// ];
-// 
-// -// **state**: standard state config for this view. This state is slightly -// special as it includes config of many of the subviews. -// -//
-// state = {
-//     query: {dataset query state - see dataset.queryState object}
-//     view-{id1}: {view-state for this view}
-//     view-{id2}: {view-state for }
-//     ...
-//     // Explorer
-//     currentView: id of current view (defaults to first view if not specified)
-//     readOnly: (default: false) run in read-only mode
-// }
-// 
-// -// Note that at present we do *not* serialize information about the actual set -// of views in use -- e.g. those specified by the views argument -- but instead -// expect either that the default views are fine or that the client to have -// initialized the MultiView with the relevant views themselves. -my.MultiView = Backbone.View.extend({ - template: ' \ -
\ -
\ - \ -
\ - \ -
\ - Results found {{docCount}} \ -
\ - \ -
\ -
\ -
\ -
\ -
\ - ', - events: { - 'click .menu-right a': '_onMenuClick', - 'click .navigation a': '_onSwitchView' - }, - - initialize: function(options) { - var self = this; - this.el = $(this.el); - this._setupState(options.state); - // Hash of 'page' views (i.e. those for whole page) keyed by page name - if (options.views) { - this.pageViews = options.views; - } else { - this.pageViews = [{ - id: 'grid', - label: 'Grid', - view: new my.Grid({ - model: this.model, - state: this.state.get('view-grid') - }), - }, { - id: 'graph', - label: 'Graph', - view: new my.Graph({ - model: this.model, - state: this.state.get('view-graph') - }), - }, { - id: 'map', - label: 'Map', - view: new my.Map({ - model: this.model, - state: this.state.get('view-map') - }), - }, { - id: 'timeline', - label: 'Timeline', - view: new my.Timeline({ - model: this.model, - state: this.state.get('view-timeline') - }), - }]; - } - // these must be called after pageViews are created - this.render(); - this._bindStateChanges(); - this._bindFlashNotifications(); - // now do updates based on state (need to come after render) - if (this.state.get('readOnly')) { - this.setReadOnly(); - } - if (this.state.get('currentView')) { - this.updateNav(this.state.get('currentView')); - } else { - this.updateNav(this.pageViews[0].id); - } - - this.model.bind('query:start', function() { - self.notify({loader: true, persist: true}); - }); - this.model.bind('query:done', function() { - self.clearNotifications(); - self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); - }); - this.model.bind('query:fail', function(error) { - self.clearNotifications(); - var msg = ''; - if (typeof(error) == 'string') { - msg = error; - } else if (typeof(error) == 'object') { - if (error.title) { - msg = error.title + ': '; - } - if (error.message) { - msg += error.message; - } - } else { - msg = 'There was an error querying the backend'; - } - self.notify({message: msg, category: 'error', persist: true}); - }); - - // retrieve basic data like fields etc - // note this.model and dataset returned are the same - this.model.fetch() - .done(function(dataset) { - self.model.query(self.state.get('query')); - }) - .fail(function(error) { - self.notify({message: error.message, category: 'error', persist: true}); - }); - }, - - setReadOnly: function() { - this.el.addClass('recline-read-only'); - }, - - render: function() { - var tmplData = this.model.toTemplateJSON(); - tmplData.views = this.pageViews; - var template = Mustache.render(this.template, tmplData); - $(this.el).html(template); - var $dataViewContainer = this.el.find('.data-view-container'); - _.each(this.pageViews, function(view, pageName) { - $dataViewContainer.append(view.view.el); - }); - var pager = new recline.View.Pager({ - model: this.model.queryState - }); - this.el.find('.recline-results-info').after(pager.el); - var queryEditor = new recline.View.QueryEditor({ - model: this.model.queryState - }); - this.el.find('.query-editor-here').append(queryEditor.el); - var filterEditor = new recline.View.FilterEditor({ - model: this.model.queryState - }); - this.$filterEditor = filterEditor.el; - this.el.find('.header').append(filterEditor.el); - var facetViewer = new recline.View.FacetViewer({ - model: this.model - }); - this.$facetViewer = facetViewer.el; - this.el.find('.header').append(facetViewer.el); - }, - - updateNav: function(pageName) { - this.el.find('.navigation a').removeClass('active'); - var $el = this.el.find('.navigation a[data-view="' + pageName + '"]'); - $el.addClass('active'); - // show the specific page - _.each(this.pageViews, function(view, idx) { - if (view.id === pageName) { - view.view.el.show(); - view.view.trigger('view:show'); - } else { - view.view.el.hide(); - view.view.trigger('view:hide'); - } - }); - }, - - _onMenuClick: function(e) { - e.preventDefault(); - var action = $(e.target).attr('data-action'); - if (action === 'filters') { - this.$filterEditor.toggle(); - } else if (action === 'facets') { - this.$facetViewer.toggle(); - } - }, - - _onSwitchView: function(e) { - e.preventDefault(); - var viewName = $(e.target).attr('data-view'); - this.updateNav(viewName); - this.state.set({currentView: viewName}); - }, - - // create a state object for this view and do the job of - // - // a) initializing it from both data passed in and other sources (e.g. hash url) - // - // b) ensure the state object is updated in responese to changes in subviews, query etc. - _setupState: function(initialState) { - var self = this; - // get data from the query string / hash url plus some defaults - var qs = my.parseHashQueryString(); - var query = qs.reclineQuery; - query = query ? JSON.parse(query) : self.model.queryState.toJSON(); - // backwards compatability (now named view-graph but was named graph) - var graphState = qs['view-graph'] || qs.graph; - graphState = graphState ? JSON.parse(graphState) : {}; - - // now get default data + hash url plus initial state and initial our state object with it - var stateData = _.extend({ - query: query, - 'view-graph': graphState, - backend: this.model.backend.__type__, - url: this.model.get('url'), - currentView: null, - readOnly: false - }, - initialState); - this.state = new recline.Model.ObjectState(stateData); - }, - - _bindStateChanges: function() { - var self = this; - // finally ensure we update our state object when state of sub-object changes so that state is always up to date - this.model.queryState.bind('change', function() { - self.state.set({query: self.model.queryState.toJSON()}); - }); - _.each(this.pageViews, function(pageView) { - if (pageView.view.state && pageView.view.state.bind) { - var update = {}; - update['view-' + pageView.id] = pageView.view.state.toJSON(); - self.state.set(update); - pageView.view.state.bind('change', function() { - var update = {}; - update['view-' + pageView.id] = pageView.view.state.toJSON(); - // had problems where change not being triggered for e.g. grid view so let's do it explicitly - self.state.set(update, {silent: true}); - self.state.trigger('change'); - }); - } - }); - }, - - _bindFlashNotifications: function() { - var self = this; - _.each(this.pageViews, function(pageView) { - pageView.view.bind('recline:flash', function(flash) { - self.notify(flash); - }); - }); - }, - - // ### notify - // - // Create a notification (a div.alert in div.alert-messsages) using provided - // flash object. Flash attributes (all are optional): - // - // * message: message to show. - // * category: warning (default), success, error - // * persist: if true alert is persistent, o/w hidden after 3s (default = false) - // * loader: if true show loading spinner - notify: function(flash) { - var tmplData = _.extend({ - message: 'Loading', - category: 'warning', - loader: false - }, - flash - ); - if (tmplData.loader) { - var _template = ' \ -
\ - {{message}} \ -   \ -
'; - } else { - var _template = ' \ -
× \ - {{message}} \ -
'; - } - var _templated = $(Mustache.render(_template, tmplData)); - _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages')); - if (!flash.persist) { - setTimeout(function() { - $(_templated).fadeOut(1000, function() { - $(this).remove(); - }); - }, 1000); - } - }, - - // ### clearNotifications - // - // Clear all existing notifications - clearNotifications: function() { - var $notifications = $('.recline-data-explorer .alert-messages .alert'); - $notifications.fadeOut(1500, function() { - $(this).remove(); - }); - } -}); - -// ### MultiView.restore -// -// Restore a MultiView instance from a serialized state including the associated dataset -my.MultiView.restore = function(state) { - var dataset = recline.Model.Dataset.restore(state); - var explorer = new my.MultiView({ - model: dataset, - state: state - }); - return explorer; -} - - -// ## Miscellaneous Utilities -var urlPathRegex = /^([^?]+)(\?.*)?/; - -// Parse the Hash section of a URL into path and query string -my.parseHashUrl = function(hashUrl) { - var parsed = urlPathRegex.exec(hashUrl); - if (parsed === null) { - return {}; - } else { - return { - path: parsed[1], - query: parsed[2] || '' - }; - } -}; - -// Parse a URL query string (?xyz=abc...) into a dictionary. -my.parseQueryString = function(q) { - if (!q) { - return {}; - } - var urlParams = {}, - e, d = function (s) { - return unescape(s.replace(/\+/g, " ")); - }, - r = /([^&=]+)=?([^&]*)/g; - - if (q && q.length && q[0] === '?') { - q = q.slice(1); - } - while (e = r.exec(q)) { - // TODO: have values be array as query string allow repetition of keys - urlParams[d(e[1])] = d(e[2]); - } - return urlParams; -}; - -// Parse the query string out of the URL hash -my.parseHashQueryString = function() { - q = my.parseHashUrl(window.location.hash).query; - return my.parseQueryString(q); -}; - -// Compse a Query String -my.composeQueryString = function(queryParams) { - var queryString = '?'; - var items = []; - $.each(queryParams, function(key, value) { - if (typeof(value) === 'object') { - value = JSON.stringify(value); - } - items.push(key + '=' + encodeURIComponent(value)); - }); - queryString += items.join('&'); - return queryString; -}; - -my.getNewHashForQueryString = function(queryParams) { - var queryPart = my.composeQueryString(queryParams); - if (window.location.hash) { - // slice(1) to remove # at start - return window.location.hash.split('?')[0].slice(1) + queryPart; - } else { - return queryPart; - } -}; - -my.setHashQueryString = function(queryParams) { - window.location.hash = my.getNewHashForQueryString(queryParams); -}; - -})(jQuery, recline.View); - -/*jshint multistr:true */ - this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; @@ -2992,6 +3127,121 @@ my.FacetViewer = Backbone.View.extend({ }); +})(jQuery, recline.View); + +/*jshint multistr:true */ + +// Field Info +// +// For each field +// +// Id / Label / type / format + +// Editor -- to change type (and possibly format) +// Editor for show/hide ... + +// Summaries of fields +// +// Top values / number empty +// If number: max, min average ... + +// Box to boot transform editor ... + +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { + +my.Fields = Backbone.View.extend({ + className: 'recline-fields-view', + template: ' \ +
\ +

Fields +

\ + {{#fields}} \ +
\ +
\ + \ +

\ + {{label}} \ + \ + {{type}} \ + » \ + \ +

\ +
\ +
\ +
\ + {{#facets}} \ +
\ +
    \ + {{#terms}} \ +
  • {{term}} [{{count}}]
  • \ + {{/terms}} \ +
\ +
\ + {{/facets}} \ +
\ +
\ +
\ +
\ + {{/fields}} \ +
\ + ', + + events: { + 'click .js-show-hide': 'onShowHide' + }, + initialize: function(model) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + + // TODO: this is quite restrictive in terms of when it is re-run + // e.g. a change in type will not trigger a re-run atm. + // being more liberal (e.g. binding to all) can lead to being called a lot (e.g. for change:width) + this.model.fields.bind('reset', function(action) { + self.model.fields.each(function(field) { + field.facets.unbind('all', self.render); + field.facets.bind('all', self.render); + }); + // fields can get reset or changed in which case we need to recalculate + self.model.getFieldsSummary(); + self.render(); + }); + this.render(); + }, + render: function() { + var self = this; + var tmplData = { + fields: [] + }; + this.model.fields.each(function(field) { + var out = field.toJSON(); + out.facets = field.facets.toJSON(); + tmplData.fields.push(out); + }); + var templated = Mustache.render(this.template, tmplData); + this.el.html(templated); + this.el.find('.collapse').collapse('hide'); + }, + onShowHide: function(e) { + e.preventDefault(); + var $target = $(e.target); + // weird collapse class seems to have been removed (can watch this happen + // if you watch dom) but could not work why. Absence of collapse then meant + // we could not toggle. + // This seems to fix the problem. + this.el.find('.accordion-body').addClass('collapse');; + if ($target.text() === '+') { + this.el.find('.collapse').collapse('show'); + $target.text('-'); + } else { + this.el.find('.collapse').collapse('hide'); + $target.text('+'); + } + } +}); + })(jQuery, recline.View); /*jshint multistr:true */ @@ -3004,49 +3254,55 @@ this.recline.View = this.recline.View || {}; my.FilterEditor = Backbone.View.extend({ className: 'recline-filter-editor well', template: ' \ - × \ -
\ -
\ -

Filters

\ -
\ -
\ -
\ -
\ -
\ - {{#termFilters}} \ -
\ - \ -
\ -
\ - \ - \ -
\ -
\ -
\ - {{/termFilters}} \ -
\ -
\ -

To add a filter use the column menu in the grid view.

\ - \ +
\ +

Filters

\ + Add filter \ + \ +
\ + \ + \ + \ + \ + \ +
\ + \ +
\ + {{#termFilters}} \ +
\ + \ +
\ + \ + × \
\ - \ -
\ +
\ + {{/termFilters}} \ + {{#termFilters.length}} \ + \ + {{/termFilters.length}} \ + \
\ ', events: { - 'click .js-hide': 'onHide', 'click .js-remove-filter': 'onRemoveFilter', - 'submit form': 'onTermFiltersUpdate' + 'click .js-add-filter': 'onAddFilterShow', + 'submit form.js-edit': 'onTermFiltersUpdate', + 'submit form.js-add': 'onAddFilter' }, initialize: function() { this.el = $(this.el); _.bindAll(this, 'render'); - this.model.bind('change', this.render); - this.model.bind('change:filters:new-blank', this.render); + this.model.queryState.bind('change', this.render); + this.model.queryState.bind('change:filters:new-blank', this.render); this.render(); }, render: function() { - var tmplData = $.extend(true, {}, this.model.toJSON()); + var tmplData = $.extend(true, {}, this.model.queryState.toJSON()); // we will use idx in list as there id ... tmplData.filters = _.map(tmplData.filters, function(filter, idx) { filter.id = idx; @@ -3064,29 +3320,38 @@ my.FilterEditor = Backbone.View.extend({ value: filter.term[fieldId] }; }); + tmplData.fields = this.model.fields.toJSON(); var out = Mustache.render(this.template, tmplData); this.el.html(out); - // are there actually any facets to show? - if (this.model.get('filters').length > 0) { - this.el.show(); - } else { - this.el.hide(); - } }, - onHide: function(e) { + onAddFilterShow: function(e) { e.preventDefault(); - this.el.hide(); + var $target = $(e.target); + $target.hide(); + this.el.find('form.js-add').show(); + }, + onAddFilter: function(e) { + e.preventDefault(); + var $target = $(e.target); + $target.hide(); + var filterType = $target.find('select.filterType').val(); + var field = $target.find('select.fields').val(); + if (filterType === 'term') { + this.model.queryState.addTermFilter(field); + } + // trigger render explicitly as queryState change will not be triggered (as blank value for filter) + this.render(); }, onRemoveFilter: function(e) { e.preventDefault(); var $target = $(e.target); var filterId = $target.closest('.filter').attr('data-filter-id'); - this.model.removeFilter(filterId); + this.model.queryState.removeFilter(filterId); }, onTermFiltersUpdate: function(e) { var self = this; e.preventDefault(); - var filters = self.model.get('filters'); + var filters = self.model.queryState.get('filters'); var $form = $(e.target); _.each($form.find('input'), function(input) { var $input = $(input); @@ -3095,8 +3360,8 @@ my.FilterEditor = Backbone.View.extend({ var fieldId = $input.attr('data-filter-field'); filters[filterIndex].term[fieldId] = value; }); - self.model.set({filters: filters}); - self.model.trigger('change'); + self.model.queryState.set({filters: filters}); + self.model.queryState.trigger('change'); } }); @@ -3553,6 +3818,19 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; if (results.error) { dfd.reject(results.error); } + + // Rename duplicate fieldIds as each field name needs to be + // unique. + var seen = {}; + _.map(results.fields, function(fieldId, index) { + if (fieldId in seen) { + seen[fieldId] += 1; + results.fields[index] = fieldId + "("+seen[fieldId]+")"; + } else { + seen[fieldId] = 1; + } + }); + dataset.fields.reset(_.map(results.fields, function(fieldId) { return {id: fieldId}; }) @@ -4109,7 +4387,7 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; var value = rawdoc[field.id]; if (value !== null) { value = value.toString(); } // TODO regexes? - foundmatch = foundmatch || (value === term); + foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase()); // TODO: early out (once we are true should break to spare unnecessary testing) // if (foundmatch) return true; });