Add archived queries section to queries list. (#2888)

* Add archived queries section to queries list.

* Refactor route building for list based controllers.

This also fixes the dashboard empty state page.
This commit is contained in:
Jannis Leidel 2019-02-03 13:35:25 +01:00 committed by Arik Fraimovich
parent b0a11983fa
commit 69e34f048a
13 changed files with 292 additions and 230 deletions

View File

@ -181,7 +181,7 @@ body {
cursor: pointer;
}
.btn-favourite {
.btn-favourite, .btn-archive {
font-size: 15px;
}
}
@ -194,7 +194,7 @@ body {
}
}
.btn-favourite {
.btn-favourite, .btn-archive {
color: #d4d4d4;
transition: all .25s ease-in-out;
@ -207,7 +207,20 @@ body {
}
}
.page-header--new .btn-favourite {
.btn-archive {
color: #d4d4d4;
transition: all .25s ease-in-out;
&:hover, &:focus {
color: @gray-light;
}
.fa-archive {
color: @gray-light;
}
}
.page-header--new .btn-favourite, .page-header--new .btn-archive {
font-size: 19px;
}
@ -243,7 +256,7 @@ body {
}
}
.navbar li a .btn-favourite .fa {
.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa {
font-size: 100%;
}

View File

@ -1,9 +1,37 @@
import { bind } from 'lodash';
import { bind, each } from 'lodash';
import $ from 'jquery';
import { LivePaginator } from '@/lib/pagination';
import { Query } from '@/services/query';
import { Dashboard } from '@/services/dashboard';
import { User } from '@/services/user';
export default class ListCtrl {
constructor($scope, $location, currentUser, clientConfig, defaultOrder = '-created_at') {
export function buildListRoutes(name, routes, template) {
const listRoutes = {};
each(routes, (route) => {
listRoutes[route.path] = {
template,
reloadOnSearch: false,
title: route.title,
resolve: {
currentPage: () => route.page,
resource: () => {
// services that are using the ListCtrl class
const listServices = {
query: Query,
dashboard: Dashboard,
user: User,
};
return listServices[name].query.bind(listServices[name]);
},
},
};
});
return listRoutes;
}
export class ListCtrl {
constructor($scope, $location, $route, currentUser, clientConfig, defaultOrder = '-created_at') {
this.title = $route.current.title; // will make it available as $ctrl.title
this.searchTerm = $location.search().q || '';
this.page = parseInt($location.search().page || 1, 10);

View File

@ -1,6 +1,5 @@
<div class="container">
<page-header title="'Dashboards'"></page-header>
<page-header title="$ctrl.title"></page-header>
<div class="row">
<div class="col-md-3 list-control-t">
<div class="m-b-10">
@ -37,22 +36,22 @@
</div>
<div ng-if="$ctrl.loaded && $ctrl.showEmptyState" class="col-md-9 list-content">
<div ng-if="($ctrl.currentPage == 'all') && ($ctrl.searchText.length == 0 || $ctrl.searchText === undefined)">
<empty-state
icon="'zmdi zmdi-view-quilt'"
description="'See the big picture'"
illustration="'dashboard'"
help-link="'https://help.redash.io/category/22-dashboards'"
show-dashboard-step="true"
></empty-state>
<div ng-switch="$ctrl.emptyType">
<div ng-switch-when="default">
<empty-state
icon="'zmdi zmdi-view-quilt'"
description="'See the big picture'"
illustration="'dashboard'"
help-link="'https://help.redash.io/category/22-dashboards'"
show-dashboard-step="true"
></empty-state>
</div>
<big-message ng-switch-when="favorites" message="'Mark dashboards as Favorite to list them here.'" icon="'fa-star'" />
<big-message ng-switch-when="search" message="'Sorry, we couldn\'t find anything.'" icon="'fa-search'"></big-message>
<no-tagged-objects-found ng-switch-when="tags" object-type="'dashboards'" tags="$ctrl.selectedTags" />
</div>
<big-message ng-if="($ctrl.currentPage == 'favorites') && ($ctrl.searchTerm === undefined || $ctrl.searchTerm.length == 0) && $ctrl.selectedTags.size === 0"
message="'Mark dashboards as Favorite to list them here.'" icon="'fa-star'" />
<big-message message="'Sorry, we couldn\'t find anything.'" icon="'fa-search'" ng-if="$ctrl.searchTerm.length > 0"></big-message>
<no-tagged-objects-found object-type="'dashboards'" tags="$ctrl.selectedTags" ng-if="$ctrl.selectedTags.size > 0" />
</div>
<div ng-if="$ctrl.loaded && !$ctrl.showEmptyState" class="col-md-9 list-content">

View File

@ -1,12 +1,10 @@
import { extend } from 'lodash';
import ListCtrl from '@/lib/list-ctrl';
import { buildListRoutes, ListCtrl } from '@/lib/list-ctrl';
import template from './dashboard-list.html';
import './dashboard-list.css';
class DashboardListCtrl extends ListCtrl {
constructor($scope, $location, currentUser, clientConfig, Dashboard) {
super($scope, $location, currentUser, clientConfig);
constructor($scope, $location, $route, currentUser, clientConfig, Dashboard) {
super($scope, $location, $route, currentUser, clientConfig);
this.Type = Dashboard;
}
@ -14,6 +12,18 @@ class DashboardListCtrl extends ListCtrl {
super.processResponse(data);
const rows = data.results.map(d => new this.Type(d));
this.paginator.updateRows(rows, data.count);
if (data.count === 0) {
if (this.isInSearchMode()) {
this.emptyType = 'search';
} else if (this.selectedTags.size > 0) {
this.emptyType = 'tags';
} else if (this.currentPage === 'favorites') {
this.emptyType = 'favorites';
} else {
this.emptyType = 'default';
}
}
this.showEmptyState = data.count === 0;
}
}
@ -23,42 +33,20 @@ export default function init(ngModule) {
template,
controller: DashboardListCtrl,
});
const routes = [
{
page: 'all',
title: 'All Dashboards',
path: '/dashboards',
},
{
page: 'favorites',
title: 'Favorite Dashboards',
path: '/dashboards/favorites',
},
];
const route = {
template: '<page-dashboard-list></page-dashboard-list>',
reloadOnSearch: false,
};
return {
'/dashboards': extend(
{
title: 'Dashboards',
resolve: {
currentPage: () => 'all',
resource(Dashboard) {
'ngInject';
return Dashboard.query.bind(Dashboard);
},
},
},
route,
),
'/dashboards/favorites': extend(
{
title: 'Favorite Dashboards',
resolve: {
currentPage: () => 'favorites',
resource(Dashboard) {
'ngInject';
return Dashboard.favorites.bind(Dashboard);
},
},
},
route,
),
};
return buildListRoutes('dashboard', routes, '<page-dashboard-list></page-dashboard-list>');
}
init.init = true;

View File

@ -1,14 +1,13 @@
import moment from 'moment';
import { extend } from 'lodash';
import ListCtrl from '@/lib/list-ctrl';
import { buildListRoutes, ListCtrl } from '@/lib/list-ctrl';
import template from './queries-list.html';
import './queries-list.css';
class QueriesListCtrl extends ListCtrl {
constructor($scope, $location, currentUser, clientConfig, Query) {
super($scope, $location, currentUser, clientConfig);
constructor($scope, $location, $route, currentUser, clientConfig, Query) {
super($scope, $location, $route, currentUser, clientConfig);
this.Type = Query;
this.showMyQueries = currentUser.hasPermission('create_query');
}
@ -32,6 +31,8 @@ class QueriesListCtrl extends ListCtrl {
this.emptyType = 'favorites';
} else if (this.currentPage === 'my') {
this.emptyType = 'my';
} else if (this.currentPage === 'archive') {
this.emptyType = 'archive';
} else {
this.emptyType = 'default';
}
@ -46,57 +47,29 @@ export default function init(ngModule) {
controller: QueriesListCtrl,
});
const route = {
template: '<page-queries-list></page-queries-list>',
reloadOnSearch: false,
};
return {
'/queries': extend(
{
title: 'Queries',
resolve: {
currentPage: () => 'all',
resource(Query) {
'ngInject';
return Query.query.bind(Query);
},
},
},
route,
),
'/queries/my': extend(
{
title: 'My Queries',
resolve: {
currentPage: () => 'my',
resource: (Query) => {
'ngInject';
return Query.myQueries.bind(Query);
},
},
},
route,
),
'/queries/favorites': extend(
{
title: 'Favorite Queries',
resolve: {
currentPage: () => 'favorites',
resource: (Query) => {
'ngInject';
return Query.favorites.bind(Query);
},
},
},
route,
),
// TODO: setup redirect?
// '/queries/search': _.extend(
};
const routes = [
{
page: 'all',
title: 'All Queries',
path: '/queries',
},
{
page: 'my',
title: 'My Queries',
path: '/queries/my',
},
{
page: 'favorites',
title: 'Favorite Queries',
path: '/queries/favorites',
},
{
page: 'archive',
title: 'Archived Queries',
path: '/queries/archive',
},
];
return buildListRoutes('query', routes, '<page-queries-list></page-queries-list>');
}
init.init = true;

View File

@ -1,6 +1,5 @@
<div class="container">
<page-header title="'Queries'"></page-header>
<page-header title="$ctrl.title"></page-header>
<div class="row">
<div class="col-md-3 list-control-t">
<div class="m-b-10">
@ -17,10 +16,19 @@
</span>
Favorites
</a>
<a href="queries/archive" class="list-group-item" ng-class="{active: $ctrl.currentPage == 'archive'}">
<span class="btn-archive">
<i class="fa fa-archive" aria-hidden="true"></i>
</span>
Archive
</a>
<a href="queries/my" class="list-group-item" ng-if="$ctrl.showMyQueries" ng-class="{active: $ctrl.currentPage == 'my'}">
<img ng-src="{{$ctrl.currentUser.profile_image_url}}" class="profile__image--navbar" width="13" style="margin-right: 0;"
/> My Queries
</a>
</div>
<div ng-if="$ctrl.currentPage != 'my'">
@ -56,6 +64,7 @@
<a href="https://redash.io/help/user-guide/querying/writing-queries">query writing documentation</a>.
</div>
<big-message ng-switch-when="archive" message="'Archived queries will be listed here.'" icon="'fa-archive'" />
<big-message ng-switch-when="favorites" message="'Mark queries as Favorite to list them here.'" icon="'fa-star'" />
<big-message ng-switch-when="search" message="'Sorry, we couldn\'t find anything.'" icon="'fa-search'"></big-message>
<no-tagged-objects-found ng-switch-when="tags" object-type="'queries'" tags="$ctrl.selectedTags" />
@ -132,10 +141,19 @@
</span>
Favorites
</a>
<a href="queries/archive" class="list-group-item" ng-class="{active: $ctrl.currentPage == 'archive'}">
<span class="btn-archive">
<i class="fa fa-archive" aria-hidden="true"></i>
</span>
Archive
</a>
<a href="queries/my" class="list-group-item" ng-if="$ctrl.showMyQueries" ng-class="{active: $ctrl.currentPage == 'my'}">
<img ng-src="{{$ctrl.currentUser.profile_image_url}}" class="profile__image--navbar" width="13" style="margin-right: 0;"
/> My Queries
</a>
</div>
<div ng-if="$ctrl.currentPage != 'my'" class="m-b-10">

View File

@ -1,12 +1,12 @@
import { extend } from 'lodash';
import { policy } from '@/services/policy';
import ListCtrl from '@/lib/list-ctrl';
import { buildListRoutes, ListCtrl } from '@/lib/list-ctrl';
import settingsMenu from '@/services/settingsMenu';
import template from './list.html';
class UsersListCtrl extends ListCtrl {
constructor($scope, $location, currentUser, clientConfig, User) {
super($scope, $location, currentUser, clientConfig);
constructor($scope, $location, $route, currentUser, clientConfig, User) {
super($scope, $location, $route, currentUser, clientConfig);
this.policy = policy;
this.enableUser = user => User.enableUser(user).then(this.update);
this.disableUser = user => User.disableUser(user).then(this.update);
@ -43,41 +43,20 @@ export default function init(ngModule) {
template,
});
const route = {
template: '<users-list-page></users-list-page>',
reloadOnSearch: false,
};
const routes = [
{
page: 'all',
title: 'All Users',
path: '/users',
},
{
page: 'disabled',
title: 'Disabled Users',
path: '/users/disabled',
},
];
return {
'/users': extend(
{
title: 'Users',
resolve: {
currentPage: () => 'all',
resource(User) {
'ngInject';
return User.query.bind(User);
},
},
},
route,
),
'/users/disabled': extend(
{
resolve: {
currentPage: () => 'disabled',
resource(User) {
'ngInject';
return User.query.bind(User);
},
},
title: 'Disabled Users',
},
route,
),
};
return buildListRoutes('user', routes, '<users-list-page></users-list-page>');
}
init.init = true;

View File

@ -335,6 +335,11 @@ function QueryResource(
isArray: true,
url: 'api/queries/recent',
},
archive: {
method: 'get',
isArray: false,
url: 'api/queries/archive',
},
query: {
isArray: false,
},

View File

@ -9,7 +9,7 @@ from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscr
from redash.handlers.dashboards import DashboardListResource, DashboardResource, DashboardShareResource, PublicDashboardResource
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource
from redash.handlers.events import EventsResource
from redash.handlers.queries import QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource
from redash.handlers.queries import QueryArchiveResource, QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource
from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource
from redash.handlers.users import UserResource, UserListResource, UserInviteResource, UserResetPasswordResource, UserDisableResource, UserRegenerateApiKeyResource
from redash.handlers.visualizations import VisualizationListResource
@ -79,6 +79,7 @@ api.add_org_resource(DashboardTagsResource, '/api/dashboards/tags', endpoint='da
api.add_org_resource(QuerySearchResource, '/api/queries/search', endpoint='queries_search')
api.add_org_resource(QueryRecentResource, '/api/queries/recent', endpoint='recent_queries')
api.add_org_resource(QueryArchiveResource, '/api/queries/archive', endpoint='queries_archive')
api.add_org_resource(QueryListResource, '/api/queries', endpoint='queries')
api.add_org_resource(MyQueriesResource, '/api/queries/my', endpoint='my_queries')
api.add_org_resource(QueryRefreshResource, '/api/queries/<query_id>/refresh', endpoint='query_refresh')

View File

@ -13,7 +13,7 @@ def organization_status(org_slug=None):
'users': models.User.all(current_org).count(),
'alerts': models.Alert.all(group_ids=current_user.group_ids).count(),
'data_sources': models.DataSource.all(current_org, group_ids=current_user.group_ids).count(),
'queries': models.Query.all_queries(current_user.group_ids, current_user.id, drafts=True).count(),
'queries': models.Query.all_queries(current_user.group_ids, current_user.id, include_drafts=True).count(),
'dashboards': models.Dashboard.query.filter(models.Dashboard.org==current_org, models.Dashboard.is_archived==False).count(),
}

View File

@ -102,7 +102,76 @@ class QueryRecentResource(BaseResource):
return QuerySerializer(results, with_last_modified_by=False, with_user=False).serialize()
class QueryListResource(BaseResource):
class BaseQueryListResource(BaseResource):
def get_queries(self, search_term):
if search_term:
results = models.Query.search(
search_term,
self.current_user.group_ids,
self.current_user.id,
include_drafts=True,
)
else:
results = models.Query.all_queries(
self.current_user.group_ids,
self.current_user.id,
include_drafts=True,
)
return filter_by_tags(results, models.Query.tags)
@require_permission('view_query')
def get(self):
"""
Retrieve a list of queries.
: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:`query <query-response-label>` objects.
"""
# See if we want to do full-text search or just regular queries
search_term = request.args.get('q', '')
queries = self.get_queries(search_term)
results = filter_by_tags(queries, models.Query.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=bool(search_term))
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 25, type=int)
response = paginate(
ordered_results,
page=page,
page_size=page_size,
serializer=QuerySerializer,
with_stats=True,
with_last_modified_by=False
)
if search_term:
self.record_event({
'action': 'search',
'object_type': 'query',
'term': search_term,
})
else:
self.record_event({
'action': 'list',
'object_type': 'query',
})
return response
class QueryListResource(BaseQueryListResource):
@require_permission('create_query')
def post(self):
"""
@ -161,68 +230,26 @@ class QueryListResource(BaseResource):
return QuerySerializer(query).serialize()
@require_permission('view_query')
def get(self):
"""
Retrieve a list of queries.
: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:`query <query-response-label>` objects.
"""
# See if we want to do full-text search or just regular queries
search_term = request.args.get('q', '')
class QueryArchiveResource(BaseQueryListResource):
def get_queries(self, search_term):
if search_term:
results = models.Query.search(
return models.Query.search(
search_term,
self.current_user.group_ids,
self.current_user.id,
include_drafts=True,
include_drafts=False,
include_archived=True,
)
else:
results = models.Query.all_queries(
return models.Query.all_queries(
self.current_user.group_ids,
self.current_user.id,
drafts=True,
include_drafts=False,
include_archived=True,
)
results = filter_by_tags(results, models.Query.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=bool(search_term))
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 25, type=int)
response = paginate(
ordered_results,
page=page,
page_size=page_size,
serializer=QuerySerializer,
with_stats=True,
with_last_modified_by=False
)
if search_term:
self.record_event({
'action': 'search',
'object_type': 'query',
'term': search_term,
})
else:
self.record_event({
'action': 'list',
'object_type': 'query',
})
return response
class MyQueriesResource(BaseResource):
@require_permission('view_query')

View File

@ -466,7 +466,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return query
@classmethod
def all_queries(cls, group_ids, user_id=None, drafts=False):
def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_archived=False):
query_ids = (
db.session
.query(distinct(cls.id))
@ -474,10 +474,10 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
DataSourceGroup,
Query.data_source_id == DataSourceGroup.data_source_id
)
.filter(Query.is_archived == False)
.filter(Query.is_archived.is_(include_archived))
.filter(DataSourceGroup.group_id.in_(group_ids))
)
q = (
queries = (
cls
.query
.options(
@ -503,19 +503,19 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
.order_by(Query.created_at.desc())
)
if not drafts:
q = q.filter(
if not include_drafts:
queries = queries.filter(
or_(
Query.is_draft == False,
Query.is_draft.is_(False),
Query.user_id == user_id
)
)
return q
return queries
@classmethod
def favorites(cls, user, base_query=None):
if base_query is None:
base_query = cls.all_queries(user.group_ids, user.id, drafts=True)
base_query = cls.all_queries(user.group_ids, user.id, include_drafts=True)
return base_query.join((
Favorite,
and_(
@ -529,7 +529,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
queries = cls.all_queries(
group_ids=user.group_ids,
user_id=user.id,
drafts=include_drafts,
include_drafts=include_drafts,
)
tag_column = func.unnest(cls.tags).label('tag')
@ -550,11 +550,13 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
@classmethod
def outdated_queries(cls):
queries = (Query.query
.options(joinedload(Query.latest_query_data).load_only('retrieved_at'))
.filter(Query.schedule.isnot(None))
.order_by(Query.id))
queries = (
Query.query
.options(joinedload(Query.latest_query_data).load_only('retrieved_at'))
.filter(Query.schedule.isnot(None))
.order_by(Query.id)
)
now = utils.utcnow()
outdated_queries = {}
scheduled_queries_executions.refresh()
@ -582,8 +584,14 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return outdated_queries.values()
@classmethod
def search(cls, term, group_ids, user_id=None, include_drafts=False, limit=None):
all_queries = cls.all_queries(group_ids, user_id=user_id, drafts=include_drafts)
def search(cls, term, group_ids, user_id=None, include_drafts=False,
limit=None, include_archived=False):
all_queries = cls.all_queries(
group_ids,
user_id=user_id,
include_drafts=include_drafts,
include_archived=include_archived,
)
# sort the result using the weight as defined in the search vector column
return all_queries.search(term, sort=True).limit(limit)

View File

@ -134,6 +134,7 @@ class TestQueryResourcePost(BaseTestCase):
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], user.id)
class TestQueryListResourceGet(BaseTestCase):
def test_returns_queries(self):
q1 = self.factory.create_query()
@ -147,8 +148,8 @@ class TestQueryListResourceGet(BaseTestCase):
def test_filters_with_tags(self):
q1 = self.factory.create_query(tags=[u'test'])
q2 = self.factory.create_query()
q3 = self.factory.create_query()
self.factory.create_query()
self.factory.create_query()
rv = self.make_request('get', '/api/queries?tags=test')
assert len(rv.json['results']) == 1
@ -157,12 +158,13 @@ class TestQueryListResourceGet(BaseTestCase):
def test_search_term(self):
q1 = self.factory.create_query(name="Sales")
q2 = self.factory.create_query(name="Q1 sales")
q3 = self.factory.create_query(name="Ops")
self.factory.create_query(name="Ops")
rv = self.make_request('get', '/api/queries?q=sales')
assert len(rv.json['results']) == 2
assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id])
class TestQueryListResourcePost(BaseTestCase):
def test_create_query(self):
query_data = {
@ -185,6 +187,27 @@ class TestQueryListResourcePost(BaseTestCase):
self.assertTrue(query.is_draft)
class TestQueryArchiveResourceGet(BaseTestCase):
def test_returns_queries(self):
q1 = self.factory.create_query(is_archived=True)
q2 = self.factory.create_query(is_archived=True)
self.factory.create_query()
rv = self.make_request('get', '/api/queries/archive')
assert len(rv.json['results']) == 2
assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id])
def test_search_term(self):
q1 = self.factory.create_query(name="Sales", is_archived=True)
q2 = self.factory.create_query(name="Q1 sales", is_archived=True)
self.factory.create_query(name="Q2 sales")
rv = self.make_request('get', '/api/queries/archive?q=sales')
assert len(rv.json['results']) == 2
assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id])
class QueryRefreshTest(BaseTestCase):
def setUp(self):
super(QueryRefreshTest, self).setUp()