Commit 1db59b00 authored by Serge S. Koval's avatar Serge S. Koval

Reworked filter UI (got idea from flask-peewee)

parent eb50adc8
- Core
- Pregenerate URLs for menu
- Calendar - add validation for time without seconds (automatically add seconds)
- View Site button?
- Model Admin
- Rework filter UI
- Rework model UI
- Number of records
- Tabs
- Filters in drop down instead of add button
- Ability to sort by fields that are not visible?
- List display callables
- Search
- Rename init_search
- Filters
- FK filters support
- Paginator class
- Custom CSS/JS in admin interface
- Checkboxes and mass operations
......
......@@ -29,16 +29,16 @@ class FilterEqual(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column == value)
def __unicode__(self):
return '%s equals' % self.name
def operation(self):
return 'equals'
class FilterNotEqual(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column != value)
def __unicode__(self):
return '%s not equal' % self.name
def operation(self):
return 'not equal'
class FilterLike(BaseSQLAFilter):
......@@ -46,8 +46,8 @@ class FilterLike(BaseSQLAFilter):
stmt = tools.parse_like_term(value)
return query.filter(self.column.ilike(stmt))
def __unicode__(self):
return '%s like' % self.name
def operation(self):
return 'like'
class FilterNotLike(BaseSQLAFilter):
......@@ -55,24 +55,24 @@ class FilterNotLike(BaseSQLAFilter):
stmt = tools.parse_like_term(value)
return query.filter(~self.column.ilike(stmt))
def __unicode__(self):
return '%s not like' % self.name
def operation(self):
return 'not like'
class FilterGreater(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column > value)
def __unicode__(self):
return '%s greater than' % self.name
def operation(self):
return 'greater than'
class FilterSmaller(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column < value)
def __unicode__(self):
return '%s smaller than' % self.name
def operation(self):
return 'smaller than'
# Customized type filters
......
from itertools import count
from flask import request, url_for, redirect, flash
from flask.ext.adminex.base import BaseView, expose
......@@ -222,12 +220,24 @@ class BaseModelView(BaseView):
self._filters = self.get_filters()
if self._filters:
self._filter_names = [unicode(n) for n in self._filters]
self._filter_groups = []
self._filter_dict = dict()
for i, n in enumerate(self._filters):
if n.name not in self._filter_dict:
group = []
self._filter_dict[n.name] = group
self._filter_groups.append((n.name, group))
else:
group = self._filter_dict[n.name]
group.append((i, n.operation()))
self._filter_types = dict((i, f.data_type)
for i, f in enumerate(self._filters)
if f.data_type)
else:
self._filter_names = None
self._filter_groups = None
self._filter_types = None
# Primary key
......@@ -518,21 +528,29 @@ class BaseModelView(BaseView):
# Gather filters
if self._filters:
filters = []
sfilters = []
for n in count():
param = 'flt%d' % n
if param not in request.args:
break
for n in request.args:
if n.startswith('flt'):
ofs = n.find('_')
if ofs == -1:
continue
idx = request.args.get(param, None, type=int)
value = request.args.get(param + 'v', None)
try:
pos = int(n[3:ofs])
idx = int(n[ofs + 1:])
except ValueError:
continue
if idx >= 0 and idx < len(self._filters):
flt = self._filters[idx]
value = request.args[n]
if flt.validate(value):
filters.append((idx, flt.clean(value)))
sfilters.append((pos, (idx, flt.clean(value))))
filters = [v[1] for v in sorted(sfilters, key=lambda n: n[0])]
else:
filters = None
......@@ -567,10 +585,8 @@ class BaseModelView(BaseView):
if filters:
for i, flt in enumerate(filters):
base = 'flt%d' % i
kwargs[base] = flt[0]
kwargs[base + 'v'] = flt[1]
key = 'flt%d_%d' % (i, flt[0])
kwargs[key] = flt[1]
return url_for(view, **kwargs)
......@@ -646,6 +662,7 @@ class BaseModelView(BaseView):
search,
filters),
# Pagination
count=count,
pager_url=pager_url,
num_pages=num_pages,
page=page,
......@@ -661,7 +678,8 @@ class BaseModelView(BaseView):
sort_desc),
search=search,
# Filters
filter_names=self._filter_names,
filters=self._filters,
filter_groups=self._filter_groups,
filter_types=self._filter_types,
filter_data=filters_data,
active_filters=filters
......@@ -672,10 +690,10 @@ class BaseModelView(BaseView):
"""
Create model view
"""
return_url = request.args.get('url')
return_url = request.args.get('url') or url_for('.index_view')
if not self.can_create:
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
form = self.create_form(request.form)
......@@ -685,7 +703,7 @@ class BaseModelView(BaseView):
flash('Model was successfully created.')
return redirect(url_for('.create_view', url=return_url))
else:
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
return self.render(self.create_template,
form=form,
......@@ -696,40 +714,40 @@ class BaseModelView(BaseView):
"""
Edit model view
"""
return_url = request.args.get('url')
return_url = request.args.get('url') or url_for('.index_view')
if not self.can_edit:
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
model = self.get_one(id)
if model is None:
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
form = self.edit_form(request.form, model)
if form.validate_on_submit():
if self.update_model(form, model):
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
return self.render(self.edit_template,
form=form,
return_url=return_url or url_for('.index_view'))
return_url=return_url)
@expose('/delete/<int:id>/', methods=('POST',))
def delete_view(self, id):
"""
Delete model view. Only POST method is allowed.
"""
return_url = request.args.get('url')
return_url = request.args.get('url') or url_for('.index_view')
# TODO: Use post
if not self.can_delete:
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
model = self.get_one(id)
if model:
self.delete_model(model)
return redirect(return_url or url_for('.index_view'))
return redirect(return_url)
......@@ -57,6 +57,14 @@ class BaseFilter(object):
"""
raise NotImplemented()
def operation(self):
"""
Return readable operation name.
For example: u'equals'
"""
raise NotImplemented()
def __unicode__(self):
return self.name
......
......@@ -5,6 +5,7 @@
display: inline-block;
zoom: 1;
*display: inline;
vertical-align: middle;
}
.chzn-container .chzn-drop {
background: #fff;
......
......@@ -21,6 +21,15 @@ a.icon {
text-decoration: none;
}
/* Model search form */
form.search-form {
margin: 4px 0 0 0;
}
form.search-form a.clear i {
margin: 2px 0 0 0;
}
/* Filters */
.filter-row {
margin: 4px;
......
var Filters = function(element, operations, options, types) {
var AdminFilters = function(element, filters_element, adminForm, operations, options, types) {
var $root = $(element)
var $container = $('#filters');
var count = $('#filters>div', $root).length;
var $container = $('.filters', $root);
var lastCount = 0;
function appendValueControl(element, id, optionId) {
var field;
// Conditionally generate select or textbox
if (optionId in options) {
field = $('<select class="filter-val" />').attr('name', 'flt' + id + 'v');
$(options[optionId]).each(function() {
field.append($('<option/>').val(this[0]).text(this[1]));
});
} else
{
field = $('<input type="text" class="filter-val" />').attr('name', 'flt' + id + 'v');
function getCount(name) {
var idx = name.indexOf('_');
return parseInt(name.substr(3, idx - 3));
}
$(element).append(field);
if (optionId in options)
field.chosen();
if (optionId in types) {
field.attr('data-role', types[optionId]);
adminForm.applyStyle(field, types[optionId]);
function changeOperation() {
var $parent = $(this).parent();
var $el = $('.filter-val', $parent);
var count = getCount($el.attr('name'));
$el.attr('name', 'flt' + count + '_' + $(this).val());
$('button', $root).show();
}
function removeFilter() {
$(this).parent().remove();
$('button', $root).show();
}
function addFilter() {
var node = $('<div class="filter-row" />').attr('id', 'fltdiv' + count).appendTo($container);
function addFilter(name, op) {
var $el = $('<div class="filter-row" />').appendTo($container);
$('<a href="#" class="remove-filter" />')
.append('<i class="icon-remove"/>')
.click(removeFilter)
.appendTo(node);
$('<a href="#" class="btn remove-filter" title="Remove Filter" />')
.text(name)
.appendTo($el)
.click(removeFilter);
var operation = $('<select class="filter-op" />')
.attr('name', 'flt' + count)
.change(changeOperation)
.appendTo(node);
var $select = $('<select class="filter-op" />')
.appendTo($el)
.change(changeOperation);
var index = 0;
$(operations).each(function() {
operation.append($('<option/>').val(index).text(this.toString()));
index++;
$(op).each(function() {
$select.append($('<option/>').attr('value', this[0]).text(this[1]));
});
operation.chosen();
appendValueControl(node, count, 0);
count += 1;
$select.chosen();
$('button', $root).show();
return false;
}
var optId = op[0][0];
function removeFilter() {
var row = $(this).parent();
var idx = parseInt(row.attr('id').substr(6));
var $field;
// Remove row
row.remove();
if (optId in options) {
$field = $('<select class="filter-val" />')
.attr('name', 'flt' + lastCount + '_' + optId)
.appendTo($el);
// Renumber any rows that are after
for (var i = idx + 1; i < count; ++i) {
row = $('#fltdiv' + i);
row.attr('id', 'fltdiv' + (i - 1));
$(options[optId]).each(function() {
$field.append($('<option/>')
.val(this[0]).text(this[1]))
.appendTo($el);
});
$('.filter-op', row).attr('name', 'flt' + (i - 1));
$('.filter-val', row).attr('name', 'flt' + (i - 1) + 'v');
$field.chosen();
} else
{
$field = $('<input type="text" class="filter-val" />')
.attr('name', 'flt' + lastCount + '_' + optId)
.appendTo($el);
}
count -= 1;
$('button', $root).show();
return false;
if (optId in types) {
$field.attr('data-role', types[optId]);
adminForm.applyStyle($field, types[optId]);
}
function changeOperation() {
var row = $(this).parent();
var rowIdx = parseInt(row.attr('id').substr(6));
// Get old value field
var oldValue = $('.filter-val', row);
var oldValueId = oldValue.attr('id');
lastCount += 1;
}
// Delete old value
oldValue.remove();
if (oldValueId != null)
$('div#' + oldValueId + '_chzn', row).remove();
$('a.filter', filters_element).click(function() {
var name = $(this).text().trim();
var optId = $(this).val();
appendValueControl(row, rowIdx, optId);
addFilter(name, operations[name]);
$('button', $root).show();
};
});
$('#add_filter', $root).click(addFilter);
$('.remove-filter', $root).click(removeFilter);
$('.filter-op').change(changeOperation);
$('.filter-val').change(function() {
$('.filter-op', $root).change(changeOperation);
$('.filter-val', $root).change(function() {
$('button', $root).show();
});
$('.remove-filter', $root).click(removeFilter);
$('.filter-val', $root).each(function() {
var count = getCount($(this).attr('name'));
if (count > lastCount)
lastCount = count;
});
lastCount += 1;
};
var adminForm = new function() {
var AdminForm = function() {
this.applyStyle = function(el, name) {
switch (name) {
case 'chosen':
......@@ -21,4 +21,4 @@ var adminForm = new function() {
$('[data-role=chosenblank]').chosen({allow_single_deselect: true});
$('[data-role=datepicker]').datepicker();
$('[data-role=datetimepicker]').datepicker({displayTime: true});
}
};
......@@ -11,6 +11,14 @@
<input name="_add_another" type="submit" class="btn btn-primary btn-large" value="Save and Add" />
{% endmacro %}
<ul class="nav nav-tabs">
<li>
<a href="{{ return_url }}">List</a>
</li>
<li class="active">
<a href="#">Create</a>
</li>
</ul>
{{ lib.render_form(form, return_url, extra()) }}
{% endblock %}
......
......@@ -7,53 +7,83 @@
{% endblock %}
{% block body %}
{% if search_supported %}
<form method="GET" action="{{ return_url }}" class="well form-search">
{% if search %}
<a href="{{ clear_search_url }}">
<i class="icon-remove"></i>
<ul class="nav nav-tabs">
<li class="active">
<a href="#">List ({{ count }})</a>
</li>
{% if admin_view.can_create %}
<li>
<a href="{{ url_for('.create_view', url=return_url) }}">Create</a>
</li>
{% endif %}
{% if filter_groups %}
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
Add Filter<b class="caret"></b>
</a>
<ul class="dropdown-menu field-filters">
{% for k in filter_groups %}
<li>
<a href="#" class="filter">{{ k[0] }}</a>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
{% if search_supported %}
<li>
<form method="GET" action="{{ return_url }}" class="search-form">
{% if sort_column is not none %}
<input type="hidden" name="sort" value="{{ sort_column }}"></input>
{% endif %}
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}"></input>
{% endif %}
<input type="text" name="search" value="{{ search or '' }}" class="span10 search-query"></input>
<button type="submit" class="btn">Search</button>
<input type="text" name="search" value="{{ search or '' }}" class="search-query span2" placeholder="Search"></input>
{% if search %}
<a href="{{ clear_search_url }}" class="clear">
<i class="icon-remove"></i>
</a>
{% endif %}
</form>
</li>
{% endif %}
</ul>
{% if filter_groups %}
<form id="filter_form" method="GET" action="{{ return_url }}">
<div class="pull-right">
<button type="submit" class="btn btn-primary" style="display: none">Apply</button>
{% if active_filters %}
<a href="{{ clear_search_url }}" class="btn">Reset Filters</a>
{% endif %}
</div>
{% if filter_names %}
<form id="filter_form" method="GET" action="{{ return_url }}" class="well">
<div id="filters">
{%- for idx, flt in enumerate(active_filters) -%}
<div id="fltdiv{{ idx }}" class="filter-row">
<a href="#" class="remove-filter"><i class="icon-remove"></i></a><select name="flt{{ idx }}" class="filter-op" data-role="chosen">
{% for optidx, opt in enumerate(filter_names) -%}
<option value="{{ optidx }}"{% if flt[0] == optidx %} selected="selected"{% endif %}>{{ opt }}</option>
{%- endfor %}
<div class="filters">
{%- for i, flt in enumerate(active_filters) -%}
<div class="filter-row">
{% set filter = admin_view._filters[flt[0]] %}
<a href="#" class="btn remove-filter" title="Remove Filter">
{{ filters[flt[0]] }}
</a><select class="filter-op" data-role="chosen">
{% for op in admin_view._filter_dict[filter.name] %}
<option value="{{ op[0] }}"{% if flt[0] == op[0] %} selected="selected"{% endif %}>{{ op[1] }}</option>
{% endfor %}
</select>
{%- set data = filter_data.get(flt[0]) -%}
{%- if data -%}
<select name="flt{{ idx }}v" class="filter-val" data-role="chosen">
{%- for opt in data %}
<option value="{{ opt[0] }}"{% if flt[1] == opt[0] %} selected{% endif %}>{{ opt[1] }}</option>
<select name="flt{{ i }}_{{ flt[0] }}" class="filter-val" data-role="chosen">
{%- for d in data %}
<option value="{{ d[0] }}"{% if flt[1] == d[0] %} selected{% endif %}>{{ d[1] }}</option>
{%- endfor %}
</select>
{%- else -%}
<input name="flt{{ idx }}v" type="text" value="{{ flt[1] or '' }}" class="filter-val"{% if flt[0] in filter_types %} data-role="{{ filter_types[flt[0]] }}"{% endif %}></input>
<input name="flt{{ i }}_{{ flt[0] }}" type="text" value="{{ flt[1] or '' }}" class="filter-val"{% if flt[0] in filter_types %} data-role="{{ filter_types[flt[0]] }}"{% endif %}></input>
{%- endif -%}
</div>
{%- endfor %}
{% endfor %}
</div>
{% if active_filters %}
<a href="{{ clear_search_url }}" class="btn">Reset Filters</a>
{% endif %}
<a id="add_filter" href="#" class="btn">Add Filter</a>
<button type="submit" class="btn" style="display: none">Apply</button>
</form>
{% endif %}
......@@ -108,19 +138,17 @@
{% endfor %}
</table>
{{ lib.pager(page, num_pages, pager_url) }}
{% if admin_view.can_create %}
<a class="btn btn-primary btn-large" href="{{ url_for('.create_view', url=return_url) }}">Create New</a>
{% endif %}
{% endblock %}
{% block tail %}
<script src="{{ url_for('admin.static', filename='js/bootstrap-datepicker.js') }}"></script>
<script src="{{ url_for('admin.static', filename='js/form.js') }}"></script>
<script src="{{ url_for('admin.static', filename='js/filters.js') }}"></script>
{% if filter_names is not none and filter_data is not none %}
{% if filter_groups is not none and filter_data is not none %}
<script language="javascript">
var filter = new Filters('#filter_form',
{{ filter_names|tojson|safe }},
var form = new AdminForm();
var filter = new AdminFilters('#filter_form', '.field-filters', form,
{{ admin_view._filter_dict|tojson|safe }},
{{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }});
</script>
......
......@@ -28,6 +28,9 @@ class SimpleFilter(filters.BaseFilter):
query._applied = True
return query
def operation(self):
return 'test'
class MockModelView(base.BaseModelView):
def __init__(self, model, name=None, category=None, endpoint=None, url=None,
......@@ -288,7 +291,8 @@ def test_column_filters():
eq_(view._filters[0].name, 'col1')
eq_(view._filters[1].name, 'col2')
eq_(view._filter_names, ['col1', 'col2'])
eq_(view._filter_dict, {'col1': [(0, 'test')],
'col2': [(1, 'test')]})
# TODO: Make calls with filters
......
......@@ -215,8 +215,10 @@ def test_column_filters():
eq_(len(view._filters), 4)
eq_(view._filter_names, ['Test1 equals', 'Test1 not equal',
'Test1 like', 'Test1 not like'])
eq_(view._filter_dict, {'Test1': [(0, 'equals'),
(1, 'not equal'),
(2, 'like'),
(3, 'not like')]})
db.session.add(Model1('model1'))
db.session.add(Model1('model2'))
......@@ -226,12 +228,12 @@ def test_column_filters():
client = app.test_client()
rv = client.get('/admin/model1view/?flt0=0&flt0v=model1')
rv = client.get('/admin/model1view/?flt0_0=model1')
eq_(rv.status_code, 200)
ok_('model1' in rv.data)
ok_('model2' not in rv.data)
rv = client.get('/admin/model1view/?flt0=5')
rv = client.get('/admin/model1view/?flt0_5=model1')
eq_(rv.status_code, 200)
ok_('model1' in rv.data)
ok_('model2' in rv.data)
......@@ -241,10 +243,8 @@ def test_column_filters():
column_filters=['int_field'])
admin.add_view(view)
eq_(view._filter_names, ['Int Field equals',
'Int Field not equal',
'Int Field greater than',
'Int Field smaller than'])
eq_(view._filter_dict, {'Int Field': [(0, 'equals'), (1, 'not equal'),
(2, 'greater than'), (3, 'smaller than')]})
def test_url_args():
......@@ -297,6 +297,7 @@ def test_url_args():
rv = client.get('/admin/model1view/?flt0=1&flt0v=data1')
ok_('data2' in rv.data)
def test_form():
# TODO: form_columns
# TODO: excluded_form_columns
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment