Merge pull request #96 from mistercrunch/filter_box

Adding a filter box widget
This commit is contained in:
Maxime Beauchemin 2015-12-29 08:26:50 -08:00
commit 3f292e6308
14 changed files with 202 additions and 22 deletions

View File

@ -16,7 +16,7 @@ It empowers its user to perform **analytics at the speed of thought**.
Panoramix provides:
* A quick way to intuitively visualize datasets
* Create and share simple dashboards
* Create and share interactive dashboards
* A rich set of visualizations to analyze your data, as well as a flexible
way to extend the capabilities
* An extensible, high granularity security model allowing intricate rules

View File

@ -2,6 +2,7 @@
List of TODO items for Panoramix
## Improvments
* Read dashboard filter from URL
* Table description is markdown
* Animated scatter plots
* Filter widget

View File

@ -251,6 +251,10 @@ class SqlaTable(Model, Queryable, AuditMixinNullable):
def __repr__(self):
return self.table_name
@property
def description_markeddown(self):
return utils.markdown(self.description)
@property
def perm(self):
return (

View File

@ -2,6 +2,15 @@ html>body{
margin: 0px; !important
}
.padded{
padding: 10px;
}
.intable-longtext{
max-height: 200px;
overflow: auto;
}
.slice_container {
height: 100%;
}

View File

@ -68,7 +68,6 @@ var px = (function() {
$('#timer').removeClass('btn-danger btn-success');
$('#timer').addClass('btn-warning');
viz.render();
console.log(slice);
$('#json').click(function(){window.location=slice.jsonEndpoint()});
$('#standalone').click(function(){window.location=slice.data.standalone_endpoint});
$('#csv').click(function(){window.location=slice.data.csv_endpoint});
@ -99,9 +98,14 @@ var px = (function() {
slices: [],
filters: {},
id: id,
addFilter: function(slice_id, field, values) {
this.filters[slice_id] = [field, values];
addFilter: function(slice_id, filters) {
this.filters[slice_id] = filters;
this.refreshExcept(slice_id);
console.log(this.filters);
},
readFilters: function() {
// Returns a list of human readable active filters
return JSON.stringify(this.filters, null, 4);
},
refreshExcept: function(slice_id) {
this.slices.forEach(function(slice){
@ -197,6 +201,7 @@ var px = (function() {
function druidify(){
prepForm();
$('div.alert').remove();
slice.render();
}

View File

@ -0,0 +1,4 @@
.select2-highlighted>.filter_box {
background-color: transparent;
border: 1px dashed black;
}

View File

@ -0,0 +1,76 @@
px.registerViz('filter_box', function(slice) {
var slice = slice;
var filtersObj = {};
d3token = d3.select(slice.selector);
var fltChanged = function() {
filters = []
for(flt in filtersObj) {
obj = filtersObj[flt];
val = obj.val()
if(val !== ''){
filters.push([flt, val.split(',')]);
}
}
slice.addFilter(filters);
}
var refresh = function() {
d3token.selectAll("*").remove();
var container = d3token
.append('div')
.classed('padded', true);
$.getJSON(slice.jsonEndpoint(), function(payload) {
var maxes = {};
for (filter in payload.data){
var data = payload.data[filter];
maxes[filter] = d3.max(data, function(d){return d.metric});
var id = 'fltbox__' + filter;
var div = container.append('div');
div.append("label").text(filter);
var sel = div
.append('div')
.attr('name', filter)
.classed('form-control', true)
.attr('multiple', '')
.attr('id', id);
filtersObj[filter] = $('#' + id).select2({
placeholder: "Select [" + filter + ']',
containment: 'parent',
dropdownAutoWidth : true,
data:data,
multiple: true,
formatResult: function(result, container, query, escapeMarkup) {
var perc = Math.round((result.metric / maxes[result.filter]) * 100);
var style = 'padding: 2px 5px;';
style += "background-image: ";
style += "linear-gradient(to right, lightgrey, lightgrey " + perc + "%, rgba(0,0,0,0) " + perc + "%";
$(container).attr('style', 'padding: 0px; background: white;');
$(container).addClass('filter_box');
return '<div style="' + style + '"><span>' + result.text + '</span></div>';
},
})
.on('change', fltChanged);
/*
.style('background-image', function(d){
if (d.isMetric){
var perc = Math.round((d.val / maxes[d.col]) * 100);
return "linear-gradient(to right, lightgrey, lightgrey " + perc + "%, rgba(0,0,0,0) " + perc + "%";
}
})
*/
}
slice.done();
})
.fail(function(xhr) {
slice.error(xhr.responseText);
});
};
return {
render: refresh,
resize: refresh,
};
});

View File

@ -55,7 +55,7 @@ px.registerViz('table', function(slice) {
} else {
table.selectAll('.filtered').classed('filtered', false);
d3.select(this).classed('filtered', true);
slice.addFilter(d.col, [d.val]);
slice.addFilter([[d.col, [d.val]]]);
}
}
})

View File

@ -54,6 +54,9 @@ body {
</div>
<div class="col-md-2">
<div class="btn-group pull-right" role="group" >
<button type="button" id="filters" class="btn btn-default" data-toggle="tooltip" title="View the list of active filters">
<i class="fa fa-filter"></i>
</button>
<button type="button" id="css" class="btn btn-default" data-toggle="modal" data-target="#css_modal">
<i class="fa fa-code"></i>
</button>
@ -88,6 +91,7 @@ body {
<nobr class="icons">
<a><i class="fa fa-arrows drag"></i></a>
<a class="refresh"><i class="fa fa-refresh"></i></a>
<a class="bug" data-slice_id="{{ slice.id }}" data-toggle="tooltip" title="console.log(this.slice);"><i class="fa fa-bug"></i></a>
</nobr>
</td>
<td>
@ -124,6 +128,12 @@ body {
$(document).ready(function() {
px.initDashboardView();
var dashboard = px.Dashboard({{ dashboard.id }});
$('#filters').click( function(){
alert(dashboard.readFilters());
});
$('a.bug').click( function(){
console.log(dashboard.getSlice($(this).data('slice_id')));
});
});
</script>
{% endblock %}

View File

@ -182,10 +182,10 @@
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Query</h4>
<h4 class="modal-title">Datasource Description</h4>
</div>
<div class="modal-body">
<pre id="query_container">{{ datasource.description }}</pre>
{{ datasource.description_markeddown | safe }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>

View File

@ -4,7 +4,7 @@
<h1><i class='fa fa-star'></i> Featured Datasets </h1>
</div>
<hr/>
<table class="table table-hover dataTable" id="dataset-table" style="display:None">
<table class="table table-hover dataTable table-bordered" id="dataset-table" style="display:None">
<thead>
<tr>
<th>Table</th>
@ -16,9 +16,11 @@
<tbody>
{% for dataset in featured_datasets %}
<tr>
<td>
<td>
<div class="intable-longtext">
<h4>{{ dataset.table_name }}</h4>
<p>{{ dataset.description }}</p>
<p>{{ utils.markdown(dataset.description) | safe }}</p>
</div>
</td>
<td class="small_table">{{ dataset.database }}</td>
<td class="small_table">{{ dataset.owner }}</td>

View File

@ -1,11 +1,14 @@
from datetime import datetime
from dateutil.parser import parse
import hashlib
from sqlalchemy.types import TypeDecorator, TEXT
import json
from flask import g, request, Markup
import parsedatetime
import functools
import hashlib
import json
from dateutil.parser import parse
from sqlalchemy.types import TypeDecorator, TEXT
from flask import g, request, Markup
from markdown import markdown as md
import parsedatetime
from panoramix import db
@ -222,3 +225,7 @@ def json_iso_dttm_ser(obj):
if isinstance(obj, datetime):
obj = obj.isoformat()
return obj
def markdown(s):
return md(s, ['markdown.extensions.tables'])

View File

@ -4,7 +4,7 @@ import logging
import re
import traceback
from flask import request, redirect, flash, Response, render_template
from flask import request, redirect, flash, Response, render_template, Markup
from flask.ext.appbuilder import ModelView, CompactCRUDMixin, BaseView, expose
from flask.ext.appbuilder.actions import action
from flask.ext.appbuilder.models.sqla.interface import SQLAInterface
@ -153,7 +153,8 @@ class TableView(PanoramixModelView, DeleteMixin):
related_views = [TableColumnInlineView, SqlMetricInlineView]
base_order = ('changed_on','desc')
description_columns = {
'offset': "Timezone offset (in hours) for this datasource"
'offset': "Timezone offset (in hours) for this datasource",
'description': Markup("Supports <a href='https://daringfireball.net/projects/markdown/'>markdown</a>"),
}
def post_add(self, table):
@ -281,7 +282,8 @@ class DatasourceModelView(PanoramixModelView, DeleteMixin):
page_size = 100
base_order = ('datasource_name', 'asc')
description_columns = {
'offset': "Timezone offset (in hours) for this datasource"
'offset': "Timezone offset (in hours) for this datasource",
'description': Markup("Supports <a href='https://daringfireball.net/projects/markdown/'>markdown</a>"),
}
def post_add(self, datasource):
@ -564,7 +566,8 @@ class Panoramix(BaseView):
featured_datasets = datasets_sqla + datasets_druid
return self.render_template(
'panoramix/featured_datasets.html',
featured_datasets=featured_datasets)
featured_datasets=featured_datasets,
utils=utils)
appbuilder.add_view_no_menu(Panoramix)
appbuilder.add_link(

View File

@ -161,8 +161,11 @@ class BaseViz(object):
extra_filters = form_data.get('extra_filters', [])
if extra_filters:
extra_filters = json.loads(extra_filters)
for slice_id, (col, vals) in extra_filters.items():
filters += [(col, 'in', ",".join(vals))]
for slice_id, slice_filters in extra_filters.items():
if slice_filters:
for col, vals in slice_filters:
if col and vals:
filters += [(col, 'in', ",".join(vals))]
return filters
@ -1105,6 +1108,61 @@ class WorldMapViz(BaseViz):
return dumps(d)
class FilterBoxViz(BaseViz):
viz_type = "filter_box"
verbose_name = "Filters"
is_timeseries = False
js_files = [
'lib/d3.min.js',
'widgets/viz_filter_box.js']
css_files = [
'widgets/viz_filter_box.css']
fieldsets = (
{
'label': None,
'fields': (
'granularity',
('since', 'until'),
'groupby',
'metric',
)
},)
form_overrides = {
'groupby': {
'label': 'Filter fields',
'description': "The fields you want to filter on",
},
}
def query_obj(self):
qry = super(FilterBoxViz, self).query_obj()
groupby = self.form_data['groupby']
if len(groupby) < 1:
raise Exception("Pick at least one filter field")
qry['metrics'] = [
self.form_data['metric']]
return qry
def get_df(self):
qry = self.query_obj()
filters = [g for g in qry['groupby']]
d = {}
for flt in filters:
qry['groupby'] = [flt]
df = super(FilterBoxViz, self).get_df(qry)
d[flt] = [
{'id': row[0],
'text': row[0],
'filter': flt,
'metric': row[1]}
for row in df.itertuples(index=False)]
return d
def get_json_data(self):
d = self.get_df()
return dumps(d)
viz_types_list = [
TableViz,
PivotTableViz,
@ -1122,6 +1180,7 @@ viz_types_list = [
DirectedForceViz,
SankeyViz,
WorldMapViz,
FilterBoxViz,
]
# This dict is used to
viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list])