datahub/src/view.js
Rufus Pollock 905659d86f [#62,view/query][m]: start of faceting support with QueryFacetEditor.
* QueryFacetEditor in DataExplorer which displays facets but nothing yet happens.
* NB: very much less than half way through (things look worse rather than better atm).
2012-03-31 15:08:27 +01:00

458 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

this.recline = this.recline || {};
this.recline.View = this.recline.View || {};
(function($, my) {
// ## DataExplorer
//
// The primary view for the entire application. Usage:
//
// <pre>
// var myExplorer = new model.recline.DataExplorer({
// model: {{recline.Model.Dataset instance}}
// el: {{an existing dom element}}
// views: {{page views}}
// config: {{config options -- see below}}
// });
// </pre>
//
// ### Parameters
//
// **model**: (required) Dataset instance.
//
// **el**: (required) DOM element.
//
// **views**: (optional) the views (Grid, Graph etc) for DataExplorer to
// show. This is an array of view hashes. If not provided
// just initialize a DataGrid with id 'grid'. Example:
//
// <pre>
// var views = [
// {
// id: 'grid', // used for routing
// label: 'Grid', // used for view switcher
// view: new recline.View.DataGrid({
// model: dataset
// })
// },
// {
// id: 'graph',
// label: 'Graph',
// view: new recline.View.FlotGraph({
// model: dataset
// })
// }
// ];
// </pre>
//
// **config**: Config options like:
//
// * readOnly: true/false (default: false) value indicating whether to
// operate in read-only mode (hiding all editing options).
//
// NB: the element already being in the DOM is important for rendering of
// FlotGraph subview.
my.DataExplorer = Backbone.View.extend({
template: ' \
<div class="data-explorer"> \
<div class="alert-messages"></div> \
\
<div class="header"> \
<ul class="navigation"> \
{{#views}} \
<li><a href="#{{id}}" class="btn">{{label}}</a> \
{{/views}} \
</ul> \
<div class="recline-results-info"> \
Results found <span class="doc-count">{{docCount}}</span> \
</div> \
</div> \
<div class="data-view-container"></div> \
<div class="dialog-overlay" style="display: none; z-index: 101; ">&nbsp;</div> \
<div class="dialog ui-draggable" style="display: none; z-index: 102; top: 101px; "> \
<div class="dialog-frame" style="width: 700px; visibility: visible; "> \
<div class="dialog-content dialog-border"></div> \
</div> \
</div> \
</div> \
',
initialize: function(options) {
var self = this;
this.el = $(this.el);
this.config = _.extend({
readOnly: false
},
options.config);
if (this.config.readOnly) {
this.setReadOnly();
}
// 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.DataGrid({
model: this.model
})
}];
}
// this must be called after pageViews are created
this.render();
this.router = new Backbone.Router();
this.setupRouting();
this.model.bind('query:start', function() {
my.notify('Loading data', {loader: true});
});
this.model.bind('query:done', function() {
my.clearNotifications();
self.el.find('.doc-count').text(self.model.docCount || 'Unknown');
my.notify('Data loaded', {category: 'success'});
// update navigation
var qs = my.parseHashQueryString();
qs['reclineQuery'] = JSON.stringify(self.model.queryState.toJSON());
var out = my.getNewHashForQueryString(qs);
self.router.navigate(out);
});
this.model.bind('query:fail', function(error) {
my.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';
}
my.notify(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) {
var queryState = my.parseHashQueryString().reclineQuery;
if (queryState) {
queryState = JSON.parse(queryState);
}
self.model.query(queryState);
})
.fail(function(error) {
my.notify(error.message, {category: 'error', persist: true});
});
},
setReadOnly: function() {
this.el.addClass('read-only');
},
render: function() {
var tmplData = this.model.toTemplateJSON();
tmplData.displayCount = this.config.displayCount;
tmplData.views = this.pageViews;
var template = $.mustache(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 queryEditor = new my.QueryEditor({
model: this.model.queryState
});
this.el.find('.header').append(queryEditor.el);
var queryFacetEditor = new my.QueryFacetEditor({
model: this.model.queryState
},
this.model
);
this.el.find('.header').append(queryFacetEditor.el);
},
setupRouting: function() {
var self = this;
// Default route
this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
self.updateNav(self.pageViews[0].id, queryString);
});
$.each(this.pageViews, function(idx, view) {
self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
self.updateNav(viewId, queryString);
});
});
},
updateNav: function(pageName, queryString) {
this.el.find('.navigation li').removeClass('active');
this.el.find('.navigation li a').removeClass('disabled');
var $el = this.el.find('.navigation li a[href=#' + pageName + ']');
$el.parent().addClass('active');
$el.addClass('disabled');
// show the specific page
_.each(this.pageViews, function(view, idx) {
if (view.id === pageName) {
view.view.el.show();
} else {
view.view.el.hide();
}
});
}
});
my.QueryEditor = Backbone.View.extend({
className: 'recline-query-editor',
template: ' \
<form action="" method="GET" class="form-inline"> \
<div class="input-prepend text-query"> \
<span class="add-on"><i class="icon-search"></i></span> \
<input type="text" name="q" value="{{q}}" class="span2" placeholder="Search data ..." class="search-query" /> \
<div class="btn-group menu"> \
<a class="btn dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i><span class="caret"></span></a> \
<ul class="dropdown-menu"> \
<li><a data-action="size" href="">Number of items to show ({{size}})</a></li> \
<li><a data-action="from" href="">Show from ({{from}})</a></li> \
</ul> \
</div> \
</div> \
<div class="pagination"> \
<ul> \
<li class="prev action-pagination-update"><a href="">&laquo;</a></li> \
<li class="active"><a>{{from}} &ndash; {{to}}</a></li> \
<li class="next action-pagination-update"><a href="">&raquo;</a></li> \
</ul> \
</div> \
</form> \
',
events: {
'submit form': 'onFormSubmit'
, 'click .action-pagination-update': 'onPaginationUpdate'
, 'click .menu li a': 'onMenuItemClick'
},
initialize: function() {
_.bindAll(this, 'render');
this.el = $(this.el);
this.model.bind('change', this.render);
this.render();
},
onFormSubmit: function(e) {
e.preventDefault();
var query = this.el.find('.text-query input').val();
this.model.set({q: query});
},
onPaginationUpdate: function(e) {
e.preventDefault();
var $el = $(e.target);
if ($el.parent().hasClass('prev')) {
var newFrom = this.model.get('from') - Math.max(0, this.model.get('size'));
} else {
var newFrom = this.model.get('from') + this.model.get('size');
}
this.model.set({from: newFrom});
},
onMenuItemClick: function(e) {
e.preventDefault();
var attrName = $(e.target).attr('data-action');
var msg = _.template('New value (<%= value %>)',
{value: this.model.get(attrName)}
);
var newValue = prompt(msg);
if (newValue) {
newValue = parseInt(newValue);
var update = {};
update[attrName] = newValue;
this.model.set(update);
}
},
render: function() {
var tmplData = this.model.toJSON();
tmplData.to = this.model.get('from') + this.model.get('size');
var templated = $.mustache(this.template, tmplData);
this.el.html(templated);
}
});
my.QueryFacetEditor = Backbone.View.extend({
className: 'recline-query-facet-editor',
template: ' \
<a class="btn dropdown-toggle" data-toggle="drop-down">Add filter on</a> \
<ul class="dropdown-menu js-faceton"> \
{{#fields}} \
<li name="{{id}}">{{label}}</li> \
{{/fields}} \
</ul> \
<div class="facets"> \
{{#facets}} \
<a class="btn js-facet-show-toggle" data-facet="{{label}}"><i class="icon-plus"></i> {{label}}</a> \
<ul class="facet-items" data-facet="{{label}}" style="display: none;"> \
{{#terms}} \
<li>{{term}} ({{count}}) <input type="checkbox" class="facet-choice" data-facet="{{label}}" value="{{term}}" /></li> \
{{/terms}} \
</ul> \
{{/facets}} \
</div> \
',
events: {
'change .js-faceton': 'onAddFilter',
'click .js-facet-show-toggle': 'onFacetShowToggle'
},
initialize: function(model, dataset) {
_.bindAll(this, 'render');
this.el = $(this.el);
this.model.bind('change', this.render);
this.dataset = dataset;
this.dataset.fields.bind('add', this.render);
this.dataset.fields.bind('reset', this.render);
this.dataset.fields.bind('remove', this.render);
this.render();
},
render: function() {
var tmplData = {
query: this.model.toJSON(),
fields: this.dataset.fields.toJSON()
};
tmplData.facets = _.map(this.dataset.facets, function(data, key) {
var out = _.extend({label: key}, data);
return out;
})
var templated = $.mustache(this.template, tmplData);
this.el.html(templated);
},
onAddFilter: function(e) {
var fieldId = $(e.target).find('option:selected').attr('name');
this.model.addFacet(fieldId);
// calculate facets for that field (if not already there??)
// re-render will happen automatically as dataset will have updated
},
onFacetShowToggle: function(e) {
e.preventDefault();
var $a = $(e.target);
var facetId = $a.attr('data-facet');
var $ul = this.el.find('.facet-items[data-facet="' + facetId + '"]');
$ul.toggle();
}
});
/* ========================================================== */
// ## 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) {
items.push(key + '=' + 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);
}
// ## notify
//
// Create a notification (a div.alert in div.alert-messsages) using provide messages and options. Options are:
//
// * category: warning (default), success, error
// * persist: if true alert is persistent, o/w hidden after 3s (default = false)
// * loader: if true show loading spinner
my.notify = function(message, options) {
if (!options) var options = {};
var tmplData = _.extend({
msg: message,
category: 'warning'
},
options);
var _template = ' \
<div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
{{msg}} \
{{#loader}} \
<span class="notification-loader">&nbsp;</span> \
{{/loader}} \
</div>';
var _templated = $.mustache(_template, tmplData);
_templated = $(_templated).appendTo($('.data-explorer .alert-messages'));
if (!options.persist) {
setTimeout(function() {
$(_templated).fadeOut(1000, function() {
$(this).remove();
});
}, 1000);
}
}
// ## clearNotifications
//
// Clear all existing notifications
my.clearNotifications = function() {
var $notifications = $('.data-explorer .alert-messages .alert');
$notifications.remove();
}
})(jQuery, recline.View);