Add server-side sorting to dashboard list. (#2760)

Fix #2771. Refs #2731.
This commit is contained in:
Jannis Leidel 2018-08-28 20:45:26 +02:00 committed by GitHub
parent bc15c0b6d1
commit bfd128413c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 148 additions and 67 deletions

View File

@ -4,7 +4,7 @@
<div class="row">
<div class="col-md-3 list-control-t">
<div class="m-b-5">
<input type='text' class='form-control' placeholder="Search Dashboards..." ng-change="$ctrl.update()" ng-model="$ctrl.searchText"
<input type='text' class='form-control' placeholder="Search Dashboards..." ng-change="$ctrl.update()" ng-model="$ctrl.searchText" ng-model-options="{ allowInvalid: true, debounce: 200 }"
autofocus/>
</div>
@ -50,9 +50,15 @@
<thead>
<tr>
<th style="width: 33px"></th>
<th>Name</th>
<th class="sortable-column" ng-click="$ctrl.paginator.orderBy('name')">
Name
<sort-icon column="'name'" sort-column="$ctrl.paginator.orderByField" reverse="$ctrl.paginator.orderByReverse"></sort-icon>
</th>
<th></th>
<th>Created At</th>
<th class="sortable-column" ng-click="$ctrl.paginator.orderBy('created_at')">
Created At
<sort-icon column="'created_at'" sort-column="$ctrl.paginator.orderByField" reverse="$ctrl.paginator.orderByReverse"></sort-icon>
</th>
</tr>
</thead>
<tbody>
@ -82,7 +88,7 @@
<div class="col-md-3 list-control-r-b">
<div class="m-b-5">
<input type='text' class='form-control' placeholder="Search Dashboards..." ng-change="$ctrl.update()" ng-model="$ctrl.searchText"
<input type='text' class='form-control' placeholder="Search Dashboards..." ng-change="$ctrl.update()" ng-model="$ctrl.searchText" ng-model-options="{ allowInvalid: true, debounce: 200 }"
autofocus/>
</div>

View File

@ -4,66 +4,94 @@ import { LivePaginator } from '@/lib/pagination';
import template from './dashboard-list.html';
import './dashboard-list.css';
function DashboardListCtrl($scope, currentUser, $location, Dashboard) {
const page = parseInt($location.search().page || 1, 10);
class DashboardListCtrl {
constructor($scope, currentUser, $location, Dashboard) {
const page = parseInt($location.search().page || 1, 10);
// use $parent because we're using a component as route target instead of controller;
// $parent refers to scope created for the page by router
this.resource = $scope.$parent.$resolve.resource;
this.currentPage = $scope.$parent.$resolve.currentPage;
this.defaultOptions = {};
this.searchText = $location.search().q;
this.currentUser = currentUser;
this.selectedTags = new Set();
this.onTagsUpdate = (tags) => {
this.selectedTags = tags;
this.update();
};
this.showEmptyState = false;
this.loaded = false;
const fetcher = (requestedPage, itemsPerPage, orderByField, orderByReverse, paginator) => {
$location.search('page', requestedPage);
const request = Object.assign({}, this.defaultOptions, {
page: requestedPage,
page_size: itemsPerPage,
tags: [...this.selectedTags], // convert Set to Array
});
if (isString(this.searchText) && this.searchText !== '') {
request.q = this.searchText;
const orderSeparator = '-';
this.pageOrder = $location.search().order || '-created_at';
this.pageOrderReverse = this.pageOrder.startsWith(orderSeparator);
if (this.pageOrderReverse) {
this.pageOrder = this.pageOrder.substr(1);
}
// use $parent because we're using a component as route target instead of controller;
// $parent refers to scope created for the page by router
this.resource = $scope.$parent.$resolve.resource;
this.currentPage = $scope.$parent.$resolve.currentPage;
this.defaultOptions = {};
this.searchText = $location.search().q;
this.currentUser = currentUser;
this.selectedTags = new Set();
this.onTagsUpdate = (tags) => {
this.selectedTags = tags;
this.update();
};
this.showEmptyState = false;
this.loaded = false;
return this.resource(request).$promise.then((data) => {
this.loaded = true;
const rows = data.results.map(d => new Dashboard(d));
paginator.updateRows(rows, data.count);
this.showEmptyState = data.count === 0;
const setSearchOrClear = (name, value) => {
if (value) {
$location.search(name, value);
} else {
$location.search(name, undefined);
}
};
const fetcher = (requestedPage, itemsPerPage, orderByField, orderByReverse, paginator) => {
$location.search('page', requestedPage);
if (orderByReverse && !orderByField.startsWith(orderSeparator)) {
orderByField = orderSeparator + orderByField;
}
setSearchOrClear('order', orderByField);
const request = Object.assign({}, this.defaultOptions, {
page: requestedPage,
page_size: itemsPerPage,
tags: [...this.selectedTags], // convert Set to Array
order: orderByField,
});
if (isString(this.searchText) && this.searchText !== '') {
request.q = this.searchText;
}
this.loaded = false;
return this.resource(request).$promise.then((data) => {
this.loaded = true;
const rows = data.results.map(d => new Dashboard(d));
paginator.updateRows(rows, data.count);
this.showEmptyState = data.count === 0;
});
};
this.paginator = new LivePaginator(fetcher, {
page,
itemsPerPage: this.pageSize,
orderByField: this.pageOrder,
orderByReverse: this.pageOrderReverse,
});
};
this.paginator = new LivePaginator(fetcher, { page });
this.navigateTo = ($event, url) => {
if ($event.altKey || $event.ctrlKey || $event.metaKey || $event.shiftKey) {
// keep default browser behavior
return;
}
$event.preventDefault();
$location.url(url);
};
this.navigateTo = ($event, url) => {
if ($event.altKey || $event.ctrlKey || $event.metaKey || $event.shiftKey) {
// keep default browser behavior
return;
}
$event.preventDefault();
$location.url(url);
};
this.update = () => {
// trigger paginator refresh
this.paginator.setPage(1);
};
this.update = () => {
// trigger paginator refresh
this.paginator.setPage(page);
};
}
}
export default function init(ngModule) {

View File

@ -1,11 +1,11 @@
from itertools import chain
from flask import request, url_for
from funcy import distinct, project, take
from funcy import project, rpartial
from flask_restful import abort
from redash import models, serializers, settings
from redash.handlers.base import BaseResource, get_object_or_404, paginate, filter_by_tags
from redash import models, serializers
from redash.handlers.base import (BaseResource, get_object_or_404, paginate,
filter_by_tags,
order_results as _order_results)
from redash.serializers import serialize_dashboard
from redash.permissions import (can_modify, require_admin_or_owner,
require_object_modify_permission,
@ -13,24 +13,61 @@ from redash.permissions import (can_modify, require_admin_or_owner,
from sqlalchemy.orm.exc import StaleDataError
# Ordering map for relationships
order_map = {
'name': 'lowercase_name',
'-name': '-lowercase_name',
'created_at': 'created_at',
'-created_at': '-created_at',
}
order_results = rpartial(_order_results, '-created_at', order_map)
class DashboardListResource(BaseResource):
@require_permission('list_dashboards')
def get(self):
"""
Lists all accessible dashboards.
:qparam number page_size: Number of queries to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number q: Full text search term
Responds with an array of :ref:`dashboard <dashboard-response-label>`
objects.
"""
search_term = request.args.get('q')
if search_term:
results = models.Dashboard.search(self.current_org, self.current_user.group_ids, self.current_user.id, search_term)
results = models.Dashboard.search(
self.current_org,
self.current_user.group_ids,
self.current_user.id,
search_term,
)
else:
results = models.Dashboard.all(self.current_org, self.current_user.group_ids, self.current_user.id)
results = models.Dashboard.all(
self.current_org,
self.current_user.group_ids,
self.current_user.id,
)
results = filter_by_tags(results, models.Dashboard.tags)
# order results according to passed order parameter
ordered_results = order_results(results)
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 25, type=int)
response = paginate(results, page, page_size, serialize_dashboard)
response = paginate(
ordered_results,
page=page,
page_size=page_size,
serializer=serialize_dashboard,
)
return response

View File

@ -1405,6 +1405,16 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one()
@hybrid_property
def lowercase_name(self):
"Optional property useful for sorting purposes."
return self.name.lower()
@lowercase_name.expression
def lowercase_name(cls):
"The SQLAlchemy expression for the property above."
return func.lower(cls.name)
def __unicode__(self):
return u"%s=%s" % (self.id, self.name)