Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change: make draft status for queries and dashboards toggleable #1353

Merged
merged 1 commit into from
Nov 22, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions migrations/0027_add_draft_toggle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from playhouse.migrate import PostgresqlMigrator, migrate

from redash.models import db
from redash import models

if __name__ == '__main__':
db.connect_db()
migrator = PostgresqlMigrator(db.database)

with db.database.transaction():
migrate(
migrator.add_column('queries', 'is_draft', models.Query.is_draft)
)
migrate(
migrator.add_column('dashboards', 'is_draft', models.Query.is_draft)
)
db.database.execute_sql("UPDATE queries SET is_draft = (name = 'New Query')")
db.close_db(None)
11 changes: 11 additions & 0 deletions rd_ui/app/scripts/controllers/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@
});
};

$scope.togglePublished = function () {
Events.record(currentUser, "toggle_published", "dashboard", $scope.dashboard.id);
$scope.dashboard.is_draft = !$scope.dashboard.is_draft;
$scope.saveInProgress = true;
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name,
layout: JSON.stringify($scope.dashboard.layout),
is_draft: $scope.dashboard.is_draft},
function() {$scope.saveInProgress = false;});

};

$scope.toggleFullscreen = function() {
$scope.isFullscreen = !$scope.isFullscreen;
$('body').toggleClass('headless');
Expand Down
8 changes: 7 additions & 1 deletion rd_ui/app/scripts/controllers/query_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
data.id = $scope.query.id;
data.version = $scope.query.version;
} else {
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options", "latest_query_data_id", "version"]);
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options", "latest_query_data_id", "version", "is_draft"]);
}

options = _.extend({}, {
Expand Down Expand Up @@ -163,6 +163,12 @@
$scope.saveQuery(undefined, {'name': $scope.query.name});
};

$scope.togglePublished = function() {
Events.record(currentUser, 'toggle_published', 'query', $scope.query.id);
$scope.query.is_draft = !$scope.query.is_draft;
$scope.saveQuery(undefined, {'is_draft': $scope.query.is_draft});
};

$scope.executeQuery = function() {
if (!$scope.canExecuteQuery()) {
return;
Expand Down
5 changes: 5 additions & 0 deletions rd_ui/app/views/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@
<li><a data-toggle="modal" hash-link hash="edit_dashboard_dialog">Edit Dashboard</a></li>
<li><a data-toggle="modal" hash-link hash="add_query_dialog">Add Widget</a></li>
<li ng-if="showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>
<li ng-if="!dashboard.is_draft"><a ng-click="togglePublished()">Unpublish Dashboard</a></li>
<li ng-if="dashboard.is_draft"><a ng-click="togglePublished()">Publish Dashboard</a></li>
<li ng-if="!dashboard.is_archived"><a ng-click="archiveDashboard()">Archive Dashboard</a></li>
</ul>
</div>
</page-header>
<div class="col-lg-12 p-5 m-b-10 bg-orange c-white" ng-if="dashboard.is_archived">
This dashboard is archived and won't appear in the dashboards list or search results.
</div>
<div class="col-lg-12 p-5 m-b-10 bg-orange c-white" ng-if="dashboard.is_draft">
This dashboard is a draft.
</div>

<div class="m-b-5">
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
Expand Down
5 changes: 5 additions & 0 deletions rd_ui/app/views/query.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ <h3>
<div class="col-lg-12 p-5 bg-orange c-white" ng-if="query.is_archived">
This query is archived and can't be used in dashboards, and won't appear in search results.
</div>
<div class="col-lg-12 p-5 bg-orange c-white" ng-if="query.is_draft">
This query is a draft.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it will be nicer to have an icon next to the query or dashboard name rather than the message. Maybe a pencil (fa fa-pencil)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. Not sure I understand the html/css enough to do that yet :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can allow edit from maintainers and I will add it.

</div>
</div>

<! -- editor -->
Expand Down Expand Up @@ -126,6 +129,8 @@ <h3>
<ul class="dropdown-menu pull-right" dropdown-menu>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"><a hash-link hash="archive-confirmation-modal" data-toggle="modal">Archive Query</a></li>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin')) && showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>
<li ng-if="query.is_draft && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"><a ng-click="togglePublished()">Publish Query</a></li>
<li ng-if="!query.is_draft && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"><a ng-click="togglePublished()">Unpublish Query</a></li>
<li ng-if="query.id != undefined"><a ng-click="showApiKey()">Show API Key</a></li>
</ul>
</div>
Expand Down
16 changes: 8 additions & 8 deletions redash/handlers/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ def get(self):
@require_permission('create_dashboard')
def post(self):
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard.create(name=dashboard_properties['name'],
org=self.current_org,
user=self.current_user,
layout='[]')

result = dashboard.to_dict()
return result
dashboard = models.Dashboard(name=dashboard_properties['name'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be models.Dashboard.create. I'm fixing this in the webpack branch, but FYI just in case you plan to use this branch before.

Also the migration sets all dashboards to be drafts.

org=self.current_org,
user=self.current_user,
is_draft=True,
layout='[]')
return dashboard.to_dict()


class DashboardResource(BaseResource):
Expand All @@ -63,7 +62,8 @@ def post(self, dashboard_slug):

require_object_modify_permission(dashboard, self.current_user)

updates = project(dashboard_properties, ('name', 'layout', 'version'))
updates = project(dashboard_properties, ('name', 'layout', 'version',
'is_draft'))
updates['changed_by'] = self.current_user

try:
Expand Down
1 change: 1 addition & 0 deletions redash/handlers/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def post(self):
query_def['user'] = self.current_user
query_def['data_source'] = data_source
query_def['org'] = self.current_org
query_def['is_draft'] = True
query = models.Query.create(**query_def)

self.record_event({
Expand Down
63 changes: 38 additions & 25 deletions redash/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ class Query(ChangeTrackingMixin, ModelTimestampsMixin, BaseVersionedModel, Belon
user = peewee.ForeignKeyField(User)
last_modified_by = peewee.ForeignKeyField(User, null=True, related_name="modified_queries")
is_archived = peewee.BooleanField(default=False, index=True)
is_draft = peewee.BooleanField(default=True, index=True)
schedule = peewee.CharField(max_length=10, null=True)
options = JSONField(default={})

Expand All @@ -719,6 +720,7 @@ def to_dict(self, with_stats=False, with_visualizations=False, with_user=True, w
'schedule': self.schedule,
'api_key': self.api_key,
'is_archived': self.is_archived,
'is_draft': self.is_draft,
'updated_at': self.updated_at,
'created_at': self.created_at,
'data_source_id': self.data_source_id,
Expand Down Expand Up @@ -771,10 +773,9 @@ def all_queries(cls, groups, drafts=False):
.order_by(cls.created_at.desc())

if drafts:
q = q.where(Query.name == 'New Query')
q = q.where(Query.is_draft == True)
else:
q = q.where(Query.name != 'New Query')

q = q.where(Query.is_draft == False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So now all the "New Query" queries will appear every where before they didn't... Not sure how I feel about this. We could change all the existing ones to drafts, but then it will be different behavior than the one going forward :\

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it'd be better to ensure that newly created queries are marked as drafts. I've updated this below.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was thinking that new queries would be drafts, and that maybe we could have a migration step that goes through the existing queries and marks as draft any queries where Query.name == 'New Query', so upon upgrade the set of draft queries wouldn't change.

The one thing we haven't addressed yet is the ability for folks to find draft queries through the search interface, since as is I think they won't show up at all unless they're explicitly promoted from draft to public. Did you have ideas re: how you want that to look, @arikfr?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 to adding this migration.

As for the other question - basically we want to maintain the balance between "open by default" to have some room for people to work in "private". I can think of a few options:

  1. Have all queries (draft or not) discoverable via search, but drafts won't appear in "Recent" queries or the main queries list.
  2. Maintain the current behavior: new query starts as a draft, but once the user gives it a name we promote it to published status. The user can choose to unpublish it.
  3. Option Visualizations workflow & object #2 + change the query rename UI to have two buttons: "Save & Publish" (default) and "Save". This to reduce friction from the flow where you want to name a draft but not publish it.

Each options has its pros and cons. The main benefit of #2 is that it maintains current behavior & easy to implement.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rafrombrc Any opinions on what our users will prefer?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option 1 is my preference, although option 3 could also work. Option 2 wouldn't be great for our needs. Our issue is that we have a lot of users, and most of the queries that each user generates are only of interest to that particular user. Having them show up in the query lists creates a lot of noise, making it very hard to find the signal that is the queries that are meant to be for public consumption. Also our users have found the "change the name and it's not a draft" behaviour to be confusing... most folks report not really understanding what drafts are and how to use them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you decide you want to implement #3, then I prefer we reduce the scope of this pull request to adding the new field and maintain current behavior (i.e. implement #2). And then in a future PR, implement the new search/recents logic. I think the search change might take time, and I want to avoid having a long living pull request hanging around.

And of course, we can implement #3 in this pull request and decide later on if we want to "upgrade" to #1.

return q

@classmethod
Expand Down Expand Up @@ -818,17 +819,22 @@ def search(cls, term, groups):

@classmethod
def recent(cls, groups, user_id=None, limit=20):
query = cls.select(Query, User).where(Event.created_at > peewee.SQL("current_date - 7")).\
join(Event, on=(Query.id == Event.object_id.cast('integer'))). \
join(DataSourceGroup, on=(Query.data_source==DataSourceGroup.data_source)). \
switch(Query).join(User).\
where(Event.action << ('edit', 'execute', 'edit_name', 'edit_description', 'view_source')).\
where(~(Event.object_id >> None)).\
where(Event.object_type == 'query'). \
where(DataSourceGroup.group << groups).\
where(cls.is_archived == False).\
group_by(Event.object_id, Query.id, User.id).\
order_by(peewee.SQL("count(0) desc"))
query = (
cls.select(Query, User)
.where(Event.created_at > peewee.SQL("current_date - 7"))
.join(Event, on=(Query.id == Event.object_id.cast('integer')))
.join(DataSourceGroup, on=(Query.data_source==DataSourceGroup.data_source))
.switch(Query).join(User)
.where(Event.action << ('edit', 'execute', 'edit_name',
'edit_description', 'toggle_published',
'view_source'))
.where(~(Event.object_id >> None))
.where(Event.object_type == 'query')
.where(DataSourceGroup.group << groups)
.where(cls.is_archived == False)
.where(cls.is_draft == False)
.group_by(Event.object_id, Query.id, User.id)
.order_by(peewee.SQL("count(0) desc")))

if user_id:
query = query.where(Event.user == user_id)
Expand Down Expand Up @@ -1077,6 +1083,7 @@ class Dashboard(ChangeTrackingMixin, ModelTimestampsMixin, BaseVersionedModel, B
layout = peewee.TextField()
dashboard_filters_enabled = peewee.BooleanField(default=False)
is_archived = peewee.BooleanField(default=False, index=True)
is_draft = peewee.BooleanField(default=False, index=True)

class Meta:
db_table = 'dashboards'
Expand Down Expand Up @@ -1129,24 +1136,29 @@ def to_dict(self, with_widgets=False, user=None):
'dashboard_filters_enabled': self.dashboard_filters_enabled,
'widgets': widgets_layout,
'is_archived': self.is_archived,
'is_draft': self.is_draft,
'updated_at': self.updated_at,
'created_at': self.created_at,
'version': self.version
}

@classmethod
def all(cls, org, groups, user_id):
query = cls.select().\
join(Widget, peewee.JOIN_LEFT_OUTER, on=(Dashboard.id == Widget.dashboard)). \
join(Visualization, peewee.JOIN_LEFT_OUTER, on=(Widget.visualization == Visualization.id)). \
join(Query, peewee.JOIN_LEFT_OUTER, on=(Visualization.query == Query.id)). \
join(DataSourceGroup, peewee.JOIN_LEFT_OUTER, on=(Query.data_source == DataSourceGroup.data_source)). \
where(Dashboard.is_archived == False). \
where((DataSourceGroup.group << groups) |
(Dashboard.user == user_id) |
(~(Widget.dashboard >> None) & (Widget.visualization >> None))). \
where(Dashboard.org == org). \
group_by(Dashboard.id)
query = (cls.select()
.join(Widget, peewee.JOIN_LEFT_OUTER,
on=(Dashboard.id == Widget.dashboard))
.join(Visualization, peewee.JOIN_LEFT_OUTER,
on=(Widget.visualization == Visualization.id))
.join(Query, peewee.JOIN_LEFT_OUTER,
on=(Visualization.query == Query.id))
.join(DataSourceGroup, peewee.JOIN_LEFT_OUTER,
on=(Query.data_source == DataSourceGroup.data_source))
.where(Dashboard.is_archived == False)
.where((DataSourceGroup.group << groups & (Dashboard.is_draft != True)) |
(Dashboard.user == user_id) |
(~(Widget.dashboard >> None) & (Widget.visualization >> None)))
.where(Dashboard.org == org)
.group_by(Dashboard.id))

return query

Expand All @@ -1162,6 +1174,7 @@ def recent(cls, org, groups, user_id, for_user=False, limit=20):
where(~(Event.object_id >> None)). \
where(Event.object_type == 'dashboard'). \
where(Dashboard.is_archived == False). \
where(Dashboard.is_draft == False). \
where(Dashboard.org == org). \
where((DataSourceGroup.group << groups) |
(Dashboard.user == user_id) |
Expand Down
2 changes: 2 additions & 0 deletions tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def __call__(self):
query='SELECT 1',
user=user_factory.create,
is_archived=False,
is_draft=False,
schedule=None,
data_source=data_source_factory.create,
org=1)
Expand All @@ -79,6 +80,7 @@ def __call__(self):
query='SELECT {{param1}}',
user=user_factory.create,
is_archived=False,
is_draft=False,
schedule=None,
data_source=data_source_factory.create,
org=1)
Expand Down
2 changes: 1 addition & 1 deletion tests/handlers/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ def test_works_for_non_owner_with_permission(self):
self.assertEqual(rv.json['last_modified_by']['id'], user.id)



class TestQueryListResourcePost(BaseTestCase):
def test_create_query(self):
query_data = {
Expand All @@ -110,6 +109,7 @@ def test_create_query(self):

query = models.Query.get_by_id(rv.json['id'])
self.assertEquals(len(list(query.visualizations)), 1)
self.assertTrue(query.is_draft)


class QueryRefreshTest(BaseTestCase):
Expand Down
26 changes: 26 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ def test_global_recent(self):
self.assertIn(q1, recent)
self.assertNotIn(q2, recent)

def test_recent_excludes_drafts(self):
q1 = self.factory.create_query()
q2 = self.factory.create_query(is_draft=True)

models.Event.create(org=self.factory.org, user=self.factory.user,
action="edit", object_type="query",
object_id=q1.id)
models.Event.create(org=self.factory.org, user=self.factory.user,
action="edit", object_type="query",
object_id=q2.id)

recent = models.Query.recent([self.factory.default_group])

self.assertIn(q1, recent)
self.assertNotIn(q2, recent)

def test_recent_for_user(self):
q1 = self.factory.create_query()
q2 = self.factory.create_query()
Expand Down Expand Up @@ -657,6 +673,16 @@ def test_returns_recent_dashboards_basic(self):
self.assertNotIn(self.w2.dashboard, models.Dashboard.recent(self.u1.org, self.u1.groups, None))
self.assertNotIn(self.w1.dashboard, models.Dashboard.recent(self.u1.org, self.u2.groups, None))

def test_recent_excludes_drafts(self):
models.Event.create(org=self.factory.org, user=self.u1, action="view",
object_type="dashboard", object_id=self.w1.dashboard.id)
models.Event.create(org=self.factory.org, user=self.u1, action="view",
object_type="dashboard", object_id=self.w2.dashboard.id)

self.w2.dashboard.update_instance(is_draft=True)
self.assertIn(self.w1.dashboard, models.Dashboard.recent(self.u1.org, self.u1.groups, None))
self.assertNotIn(self.w2.dashboard, models.Dashboard.recent(self.u1.org, self.u1.groups, None))

def test_returns_recent_dashboards_created_by_user(self):
d1 = self.factory.create_dashboard(user=self.u1)
models.Event.create(org=self.factory.org, user=self.u1, action="view",
Expand Down