• Jump To … +
    backend.dataproxy.js backend.memory.js ecma-fixes.js model.js view.flot.js view.graph.js view.grid.js view.map.js view.multiview.js view.slickgrid.js view.timeline.js widget.facetviewer.js widget.fields.js widget.filtereditor.js widget.pager.js widget.queryeditor.js widget.valuefilter.js
  • view.multiview.js

  • ¶
    /*jshint multistr:true */
  • ¶

    Standard JS module setup

    this.recline = this.recline || {};
    this.recline.View = this.recline.View || {};
    
    (function($, my) {
      "use strict";
  • ¶

    MultiView

    Manage multiple views together along with query editor etc. Usage:

    var myExplorer = new recline.View.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
        })
      }
    ];
    

    sidebarViews: (optional) the sidebar views (Filters, Fields) for MultiView to show. This is an array of view hashes. If not provided initialize with (recline.View.)FilterEditor and Fields views (with obvious id and labels!).

    var sidebarViews = [
      {
        id: 'filterEditor', // used for routing
        label: 'Filters', // used for view switcher
        view: new recline.View.FilterEditor({
          model: dataset
        })
      },
      {
        id: 'fieldsView',
        label: 'Fields',
        view: new recline.View.Fields({
          model: dataset
        })
      }
    ];
    

    state: standard state config for this view. This state is slightly special as it includes config of many of the subviews.

    var state = {
        query: {dataset query state - see dataset.queryState object}
        'view-{id1}': {view-state for this view}
        'view-{id2}': {view-state for }
        ...
        // Explorer
        currentView: id of current view (defaults to first view if not specified)
        readOnly: (default: false) run in read-only mode
    }
    

    Note that at present we do not serialize information about the actual set of views in use — e.g. those specified by the views argument — but instead expect either that the default views are fine or that the client to have initialized the MultiView with the relevant views themselves.

    my.MultiView = Backbone.View.extend({
      template: ' \
      <div class="recline-data-explorer"> \
        <div class="alert-messages"></div> \
        \
        <div class="header clearfix"> \
          <div class="navigation"> \
            <div class="btn-group" data-toggle="buttons-radio"> \
            {{#views}} \
            <button href="#{{id}}" data-view="{{id}}" class="btn btn-default">{{label}}</button> \
            {{/views}} \
            </div> \
          </div> \
          <div class="recline-results-info"> \
            <span class="doc-count">{{recordCount}}</span> records\
          </div> \
          <div class="menu-right"> \
            <div class="btn-group" data-toggle="buttons-checkbox"> \
              {{#sidebarViews}} \
              <button href="#" data-action="{{id}}" class="btn btn-default">{{label}}</button> \
              {{/sidebarViews}} \
            </div> \
          </div> \
          <div class="query-editor-here" style="display:inline;"></div> \
        </div> \
        <div class="data-view-sidebar"></div> \
        <div class="data-view-container"></div> \
      </div> \
      ',
      events: {
        'click .menu-right button': '_onMenuClick',
        'click .navigation button': '_onSwitchView'
      },
    
      initialize: function(options) {
        var self = this;
        this._setupState(options.state);
  • ¶

    Hash of ‘page’ views (i.e. those for whole page) keyed by page name

        if (options.views) {
          this.pageViews = options.views;
        } else {
          this.pageViews = [{
            id: 'grid',
            label: 'Grid',
            view: new my.SlickGrid({
              model: this.model,
              state: this.state.get('view-grid')
            })
          }, {
            id: 'graph',
            label: 'Graph',
            view: new my.Graph({
              model: this.model,
              state: this.state.get('view-graph')
            })
          }, {
            id: 'map',
            label: 'Map',
            view: new my.Map({
              model: this.model,
              state: this.state.get('view-map')
            })
          }, {
            id: 'timeline',
            label: 'Timeline',
            view: new my.Timeline({
              model: this.model,
              state: this.state.get('view-timeline')
            })
          }];
        }
  • ¶

    Hashes of sidebar elements

        if(options.sidebarViews) {
          this.sidebarViews = options.sidebarViews;
        } else {
          this.sidebarViews = [{
            id: 'filterEditor',
            label: 'Filters',
            view: new my.FilterEditor({
              model: this.model
            })
          }, {
            id: 'fieldsView',
            label: 'Fields',
            view: new my.Fields({
              model: this.model
            })
          }];
        }
  • ¶

    these must be called after pageViews are created

        this.render();
        this._bindStateChanges();
        this._bindFlashNotifications();
  • ¶

    now do updates based on state (need to come after render)

        if (this.state.get('readOnly')) {
          this.setReadOnly();
        }
        if (this.state.get('currentView')) {
          this.updateNav(this.state.get('currentView'));
        } else {
          this.updateNav(this.pageViews[0].id);
        }
        this._showHideSidebar();
    
        this.listenTo(this.model, 'query:start', function() {
          self.notify({loader: true, persist: true});
        });
        this.listenTo(this.model, 'query:done', function() {
          self.clearNotifications();
          self.$el.find('.doc-count').text(self.model.recordCount || 'Unknown');
        });
        this.listenTo(this.model, 'query:fail', function(error) {
          self.clearNotifications();
          var msg = '';
          if (typeof(error) == 'string') {
            msg = error;
          } else if (typeof(error) == 'object') {
            if (error.title) {
              msg = error.title + ': ';
            }
            if (error.message) {
              msg += error.message;
            }
          } else {
            msg = 'There was an error querying the backend';
          }
          self.notify({message: msg, category: 'error', persist: true});
        });
  • ¶

    retrieve basic data like fields etc note this.model and dataset returned are the same TODO: set query state …?

        this.model.queryState.set(self.state.get('query'), {silent: true});
      },
    
      setReadOnly: function() {
        this.$el.addClass('recline-read-only');
      },
    
      render: function() {
        var tmplData = this.model.toTemplateJSON();
        tmplData.views = this.pageViews;
        tmplData.sidebarViews = this.sidebarViews;
        var template = Mustache.render(this.template, tmplData);
        this.$el.html(template);
  • ¶

    now create and append other views

        var $dataViewContainer = this.$el.find('.data-view-container');
        var $dataSidebar = this.$el.find('.data-view-sidebar');
  • ¶

    the main views

        _.each(this.pageViews, function(view, pageName) {
          view.view.render();
          if (view.view.redraw) {
            view.view.redraw();
          }
          $dataViewContainer.append(view.view.el);
          if (view.view.elSidebar) {
            $dataSidebar.append(view.view.elSidebar);
          }
        });
    
        _.each(this.sidebarViews, function(view) {
          this['$'+view.id] = view.view.$el;
          $dataSidebar.append(view.view.el);
        }, this);
    
        this.pager = new recline.View.Pager({
          model: this.model
        });
        this.$el.find('.recline-results-info').after(this.pager.el);
    
        this.queryEditor = new recline.View.QueryEditor({
          model: this.model.queryState
        });
        this.$el.find('.query-editor-here').append(this.queryEditor.el);
    
      },
    
      remove: function () {
        _.each(this.pageViews, function (view) {
          view.view.remove();
        });
        _.each(this.sidebarViews, function (view) {
          view.view.remove();
        });
        this.pager.remove();
        this.queryEditor.remove();
        Backbone.View.prototype.remove.apply(this, arguments);
      },
  • ¶

    hide the sidebar if empty

      _showHideSidebar: function() {
        var $dataSidebar = this.$el.find('.data-view-sidebar');
        var visibleChildren = $dataSidebar.children().filter(function() {
          return $(this).css("display") != "none";
        }).length;
    
        if (visibleChildren > 0) {
          $dataSidebar.show();
        } else {
          $dataSidebar.hide();
        }
      },
    
      updateNav: function(pageName) {
        this.$el.find('.navigation button').removeClass('active');
        var $el = this.$el.find('.navigation button[data-view="' + pageName + '"]');
        $el.addClass('active');
  • ¶

    add/remove sidebars and hide inactive views

        _.each(this.pageViews, function(view, idx) {
          if (view.id === pageName) {
            view.view.$el.show();
            if (view.view.elSidebar) {
              view.view.elSidebar.show();
            }
          } else {
            view.view.$el.hide();
            if (view.view.elSidebar) {
              view.view.elSidebar.hide();
            }
            if (view.view.hide) {
              view.view.hide();
            }
          }
        });
    
        this._showHideSidebar();
  • ¶

    call view.view.show after sidebar visibility has been determined so that views can correctly calculate their maximum width

        _.each(this.pageViews, function(view, idx) {
          if (view.id === pageName) {
            if (view.view.show) {
              view.view.show();
            }
          }
        });
      },
    
      _onMenuClick: function(e) {
        e.preventDefault();
        var action = $(e.target).attr('data-action');
        this['$'+action].toggle();
        this._showHideSidebar();
      },
    
      _onSwitchView: function(e) {
        e.preventDefault();
        var viewName = $(e.target).attr('data-view');
        this.updateNav(viewName);
        this.state.set({currentView: viewName});
      },
  • ¶

    create a state object for this view and do the job of

    a) initializing it from both data passed in and other sources (e.g. hash url)

    b) ensure the state object is updated in responese to changes in subviews, query etc.

      _setupState: function(initialState) {
        var self = this;
  • ¶

    get data from the query string / hash url plus some defaults

        var qs = my.parseHashQueryString();
        var query = qs.reclineQuery;
        query = query ? JSON.parse(query) : self.model.queryState.toJSON();
  • ¶

    backwards compatability (now named view-graph but was named graph)

        var graphState = qs['view-graph'] || qs.graph;
        graphState = graphState ? JSON.parse(graphState) : {};
  • ¶

    now get default data + hash url plus initial state and initial our state object with it

        var stateData = _.extend({
            query: query,
            'view-graph': graphState,
            backend: this.model.backend.__type__,
            url: this.model.get('url'),
            dataset: this.model.toJSON(),
            currentView: null,
            readOnly: false
          },
          initialState);
        this.state = new recline.Model.ObjectState(stateData);
      },
    
      _bindStateChanges: function() {
        var self = this;
  • ¶

    finally ensure we update our state object when state of sub-object changes so that state is always up to date

        this.listenTo(this.model.queryState, 'change', function() {
          self.state.set({query: self.model.queryState.toJSON()});
        });
        _.each(this.pageViews, function(pageView) {
          if (pageView.view.state && pageView.view.state.bind) {
            var update = {};
            update['view-' + pageView.id] = pageView.view.state.toJSON();
            self.state.set(update);
            self.listenTo(pageView.view.state, 'change', function() {
              var update = {};
              update['view-' + pageView.id] = pageView.view.state.toJSON();
  • ¶

    had problems where change not being triggered for e.g. grid view so let’s do it explicitly

              self.state.set(update, {silent: true});
              self.state.trigger('change');
            });
          }
        });
      },
    
      _bindFlashNotifications: function() {
        var self = this;
        _.each(this.pageViews, function(pageView) {
          self.listenTo(pageView.view, 'recline:flash', function(flash) {
            self.notify(flash);
          });
        });
      },
  • ¶

    notify

    Create a notification (a div.alert in div.alert-messsages) using provided flash object. Flash attributes (all are optional):

    • message: message to show.
    • category: warning (default), success, error
    • persist: if true alert is persistent, o/w hidden after 3s (default = false)
    • loader: if true show loading spinner
      notify: function(flash) {
        var tmplData = _.extend({
          message: 'Loading',
          category: 'warning',
          loader: false
          },
          flash
        );
        var _template;
        if (tmplData.loader) {
          _template = ' \
            <div class="alert alert-info alert-loader"> \
              {{message}} \
              <span class="notification-loader">&nbsp;</span> \
            </div>';
        } else {
          _template = ' \
            <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
              {{message}} \
            </div>';
        }
        var _templated = $(Mustache.render(_template, tmplData));
        _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
        if (!flash.persist) {
          setTimeout(function() {
            $(_templated).fadeOut(1000, function() {
              $(this).remove();
            });
          }, 1000);
        }
      },
  • ¶

    clearNotifications

    Clear all existing notifications

      clearNotifications: function() {
        var $notifications = $('.recline-data-explorer .alert-messages .alert');
        $notifications.fadeOut(1500, function() {
          $(this).remove();
        });
      }
    });
  • ¶

    MultiView.restore

    Restore a MultiView instance from a serialized state including the associated dataset

    This inverts the state serialization process in Multiview

    my.MultiView.restore = function(state) {
  • ¶

    hack-y - restoring a memory dataset does not mean much … (but useful for testing!)

      var datasetInfo;
      if (state.backend === 'memory') {
        datasetInfo = {
          backend: 'memory',
          records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}]
        };
      } else {
        datasetInfo = _.extend({
            url: state.url,
            backend: state.backend
          },
          state.dataset
        );
      }
      var dataset = new recline.Model.Dataset(datasetInfo);
      var explorer = new my.MultiView({
        model: dataset,
        state: state
      });
      return explorer;
    };
  • ¶

    Miscellaneous Utilities

    var urlPathRegex = /^([^?]+)(\?.*)?/;
  • ¶

    Parse the Hash section of a URL into path and query string

    my.parseHashUrl = function(hashUrl) {
      var parsed = urlPathRegex.exec(hashUrl);
      if (parsed === null) {
        return {};
      } else {
        return {
          path: parsed[1],
          query: parsed[2] || ''
        };
      }
    };
  • ¶

    Parse a URL query string (?xyz=abc…) into a dictionary.

    my.parseQueryString = function(q) {
      if (!q) {
        return {};
      }
      var urlParams = {},
        e, d = function (s) {
          return unescape(s.replace(/\+/g, " "));
        },
        r = /([^&=]+)=?([^&]*)/g;
    
      if (q && q.length && q[0] === '?') {
        q = q.slice(1);
      }
      while (e = r.exec(q)) {
  • ¶

    TODO: have values be array as query string allow repetition of keys

        urlParams[d(e[1])] = d(e[2]);
      }
      return urlParams;
    };
  • ¶

    Parse the query string out of the URL hash

    my.parseHashQueryString = function() {
      var q = my.parseHashUrl(window.location.hash).query;
      return my.parseQueryString(q);
    };
  • ¶

    Compse a Query String

    my.composeQueryString = function(queryParams) {
      var queryString = '?';
      var items = [];
      $.each(queryParams, function(key, value) {
        if (typeof(value) === 'object') {
          value = JSON.stringify(value);
        }
        items.push(key + '=' + encodeURIComponent(value));
      });
      queryString += items.join('&');
      return queryString;
    };
    
    my.getNewHashForQueryString = function(queryParams) {
      var queryPart = my.composeQueryString(queryParams);
      if (window.location.hash) {
  • ¶

    slice(1) to remove # at start

        return window.location.hash.split('?')[0].slice(1) + queryPart;
      } else {
        return queryPart;
      }
    };
    
    my.setHashQueryString = function(queryParams) {
      window.location.hash = my.getNewHashForQueryString(queryParams);
    };
    
    })(jQuery, recline.View);