From 4b403de688e8d5bf6f03693a940df417fab718e4 Mon Sep 17 00:00:00 2001 From: Alison Date: Tue, 8 Aug 2017 15:59:27 -0500 Subject: [PATCH] add ability to add query to dashboard from query page (re #154) --- .../app/pages/queries/add-to-dashboard.html | 23 ++++++ client/app/pages/queries/add-to-dashboard.js | 72 +++++++++++++++++++ client/app/pages/queries/query.html | 1 + client/app/pages/queries/view.js | 12 ++++ client/app/services/dashboard.js | 1 + redash/handlers/api.py | 3 +- redash/handlers/dashboards.py | 19 ++++- redash/models.py | 24 +++++++ tests/handlers/test_dashboards.py | 40 +++++++++++ tests/handlers/test_widgets.py | 12 ++++ 10 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 client/app/pages/queries/add-to-dashboard.html create mode 100644 client/app/pages/queries/add-to-dashboard.js diff --git a/client/app/pages/queries/add-to-dashboard.html b/client/app/pages/queries/add-to-dashboard.html new file mode 100644 index 0000000000..1f5e6f027a --- /dev/null +++ b/client/app/pages/queries/add-to-dashboard.html @@ -0,0 +1,23 @@ + + diff --git a/client/app/pages/queries/add-to-dashboard.js b/client/app/pages/queries/add-to-dashboard.js new file mode 100644 index 0000000000..5eca59975c --- /dev/null +++ b/client/app/pages/queries/add-to-dashboard.js @@ -0,0 +1,72 @@ +import template from './add-to-dashboard.html'; + +const AddToDashboardForm = { + controller($sce, Dashboard, currentUser, toastr, Query, Widget) { + 'ngInject'; + + this.query = this.resolve.query; + this.vis = this.resolve.vis; + this.saveAddToDashbosard = this.resolve.saveAddToDashboard; + this.saveInProgress = false; + + this.trustAsHtml = html => $sce.trustAsHtml(html); + + this.onDashboardSelected = (dash) => { + // add widget to dashboard + this.saveInProgress = true; + this.widgetSize = 1; + this.selectedVis = null; + this.query = {}; + this.selected_query = this.query.id; + this.type = 'visualization'; + this.isVisualization = () => this.type === 'visualization'; + + const widget = new Widget({ + visualization_id: this.vis && this.vis.id, + dashboard_id: dash.id, + options: {}, + width: this.widgetSize, + type: this.type, + }); + + // (response) + widget.$save().then(() => { + // (dashboard) + this.selectedDashboard = Dashboard.get({ slug: dash.slug }, () => {}); + this.close(); + }).catch(() => { + toastr.error('Widget can not be added'); + }).finally(() => { + this.saveInProgress = false; + }); + }; + + this.selectedDashboard = null; + + this.searchDashboards = (term) => { // , limitToUsersDashboards + if (!term || term.length < 3) { + return; + } + + Dashboard.search({ + q: term, + user_id: currentUser.id, + // limit_to_users_dashboards: limitToUsersDashboards, + include_drafts: true, + }, (results) => { + this.dashboards = results; + }); + }; + }, + bindings: { + resolve: '<', + close: '&', + dismiss: '&', + vis: '<', + }, + template, +}; + +export default function (ngModule) { + ngModule.component('addToDashboardDialog', AddToDashboardForm); +} diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index d2bf0ca0ec..f1af503c7c 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -227,6 +227,7 @@

× + +
  • + New Visualization
  • diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index ec39bb51f1..407fa60c74 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -417,6 +417,18 @@ function QueryViewCtrl( }); }; + $scope.openAddToDashboardForm = (vis) => { + $uibModal.open({ + component: 'addToDashboardDialog', + size: 'sm', + resolve: { + query: $scope.query, + vis, + saveAddToDashboard: () => $scope.saveAddToDashboard, + }, + }); + }; + $scope.showEmbedDialog = (query, visId) => { const visualization = getVisualization(visId); $uibModal.open({ diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 7c51493eaa..78525ce5dc 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -69,6 +69,7 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) get: { method: 'GET', transformResponse: transform }, save: { method: 'POST', transformResponse: transform }, query: { method: 'GET', isArray: true, transformResponse: transform }, + search: { method: 'GET', isArray: true, url: 'api/dashboards/search' }, recent: { method: 'get', isArray: true, diff --git a/redash/handlers/api.py b/redash/handlers/api.py index c11e2aeada..9c2dd83361 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -6,7 +6,7 @@ from redash.handlers.base import org_scoped_rule from redash.handlers.permissions import ObjectPermissionsListResource, CheckPermissionResource from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscriptionListResource, AlertSubscriptionResource -from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource, PublicDashboardResource +from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource, PublicDashboardResource, SearchDashboardResource from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource, DataSourceVersionResource from redash.handlers.events import EventsResource from redash.handlers.queries import QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource, QueryVersionListResource, ChangeResource @@ -50,6 +50,7 @@ def json_representation(data, code, headers=None): api.add_org_resource(DashboardResource, '/api/dashboards/', endpoint='dashboard') api.add_org_resource(PublicDashboardResource, '/api/dashboards/public/', endpoint='public_dashboard') api.add_org_resource(DashboardShareResource, '/api/dashboards//share', endpoint='dashboard_share') +api.add_org_resource(SearchDashboardResource, '/api/dashboards/search') api.add_org_resource(DataSourceTypeListResource, '/api/data_sources/types', endpoint='data_source_types') api.add_org_resource(DataSourceListResource, '/api/data_sources', endpoint='data_sources') diff --git a/redash/handlers/dashboards.py b/redash/handlers/dashboards.py index 2fa1aa9484..4c5f64c090 100644 --- a/redash/handlers/dashboards.py +++ b/redash/handlers/dashboards.py @@ -64,7 +64,6 @@ def post(self): models.db.session.commit() return dashboard.to_dict() - class DashboardResource(BaseResource): @require_permission('list_dashboards') def get(self, dashboard_slug=None): @@ -241,3 +240,21 @@ def delete(self, dashboard_id): 'object_id': dashboard.id, 'object_type': 'dashboard', }) + +class SearchDashboardResource(BaseResource): + @require_permission('list_dashboards') + def get(self): + """ + Searches for a dashboard. + + Sends to models.py > Dashboard > search() + search(cls, term, user_id, group_ids, limit_to_users_dashboards=False, include_drafts=False) + """ + term = request.args.get('q', '') + include_drafts = request.args.get('include_drafts') is not None + user_id = request.args.get('user_id', '') + group_ids = self.current_user.group_ids + if group_ids == None and request.args.get('test',False): + group_ids = [2] # the array that's used for test factory users + return [q.to_dict() for q in models.Dashboard.search(term, user_id, group_ids, include_drafts=include_drafts)] + diff --git a/redash/models.py b/redash/models.py index 2d95412d85..fa831eb3e4 100644 --- a/redash/models.py +++ b/redash/models.py @@ -1381,6 +1381,30 @@ def all(cls, org, group_ids, user_id): return query + @classmethod + def search(cls, term, user_id, group_ids, include_drafts=False): + # limit_to_users_dashboards=False, + # TODO: This is very naive implementation of search, to be replaced with PostgreSQL full-text-search solution. + where = (Dashboard.name.ilike(u"%{}%".format(term))) + + if term.isdigit(): + where |= Dashboard.id == term + + #if limit_to_users_dashboards: + # where &= Dashboard.user_id == user_id + + where &= Dashboard.is_archived == False + + if not include_drafts: + where &= Dashboard.is_draft == False + + where &= DataSourceGroup.group_id.in_(group_ids) + dashboard_ids = ( + db.session.query(Dashboard.id) + .filter(where)).distinct() + + return Dashboard.query.filter(Dashboard.id.in_(dashboard_ids)) + @classmethod def recent(cls, org, group_ids, user_id, for_user=False, limit=20): query = (Dashboard.query diff --git a/tests/handlers/test_dashboards.py b/tests/handlers/test_dashboards.py index 043e37058c..348f691644 100644 --- a/tests/handlers/test_dashboards.py +++ b/tests/handlers/test_dashboards.py @@ -4,6 +4,18 @@ from redash.permissions import ACCESS_TYPE_MODIFY +class TestRecentDashboardResourceGet(BaseTestCase): + def test_get_recent_dashboard_list_does_not_include_deleted(self): + d1 = self.factory.create_dashboard() + expected = d1.to_dict() + d2 = self.factory.create_dashboard() # this shouldn't be required but test fails without it + rv = self.make_request('post', '/api/dashboards/{0}'.format(d1.id), + data={'name': 'New Name', 'layout': '[]', 'is_archived': True}) + rvrecent = self.make_request('get', '/api/dashboards/recent') + self.assertEquals(rvrecent.status_code, 200) + actual = json.loads(rvrecent.data) + self.assertNotIn(expected['id'], actual) + class TestDashboardListResource(BaseTestCase): def test_create_new_dashboard(self): dashboard_name = 'Test Dashboard' @@ -151,3 +163,31 @@ def test_requires_admin_or_owner(self): res = self.make_request('delete', '/api/dashboards/{}/share'.format(dashboard.id), user=user) self.assertEqual(res.status_code, 200) + +class TestDashboardSearchResourceGet(BaseTestCase): + def create_dashboard_sequence(self): + d1 = self.factory.create_dashboard() + new_name = 'Analytics' + rv1 = self.make_request('post', '/api/dashboards/{0}'.format(d1.id), + data={'name': new_name, 'layout': '[]', 'is_draft': False}) + d2 = self.factory.create_dashboard() + rv2 = self.make_request('post', '/api/dashboards/{0}'.format(d2.id), + data={'name': 'Metrics', 'layout': '[]', 'is_draft': True}) + user = self.factory.create_user() + return d1, d2, user + + def test_get_dashboard_search_results_does_not_contain_deleted(self): + d1, d2, user = self.create_dashboard_sequence() + res = self.make_request('delete', '/api/dashboards/{}/share'.format(d2.id)) + dash_search_list = self.make_request('get','/api/dashboards/search?q=Metrics') + dash_search_list_json = json.loads(dash_search_list.data) + self.assertNotIn(d2.id, dash_search_list_json) + + def test_get_dashboard_search_results_obeys_draft_flag(self): + d1, d2, user = self.create_dashboard_sequence() + dash_search_list = self.make_request('get','/api/dashboards/search?q=Metrics&test=True&user_id={}'.format(user.id)) + dash_search_list_json = json.loads(dash_search_list.data) + self.assertNotIn(d2.id, dash_search_list_json) + #self.assertIn(d1.id, dash_search_list_json) + + diff --git a/tests/handlers/test_widgets.py b/tests/handlers/test_widgets.py index 702ef6f828..cb89caab47 100644 --- a/tests/handlers/test_widgets.py +++ b/tests/handlers/test_widgets.py @@ -64,3 +64,15 @@ def test_delete_widget(self): self.assertEquals(rv.status_code, 200) dashboard = models.Dashboard.get_by_slug_and_org(widget.dashboard.slug, widget.dashboard.org) self.assertEquals(dashboard.widgets.count(), 0) + + def test_updates_textbox_widget(self): + widget = self.factory.create_widget() + + rv = self.make_request('post', '/api/widgets/{0}'.format(widget.id), data={'width':2,'text':'sing and shine on', 'options': {}}) + + self.assertEquals(rv.status_code, 200) + dashboard = models.Dashboard.get_by_slug_and_org(widget.dashboard.slug, widget.dashboard.org) + self.assertEquals(dashboard.widgets.count(), 1) + self.assertEquals(dashboard.layout, '[]') + +