Repository: getredash/redash

Issue:
Suggestion: Dashboard-level formatting and description
1. Would be useful to have a general description for an entire dashboard (not for each individual chart). That way you can explain the purpose of this dashboard.
2. Ability to add sections in a dashboard. Each section will be defined by a Heading, A short description for THAT section (see above), and the graphs. This could be edited either as another "graph" in the drag & drop layout editor that currently exists, or via a textual formatter (see #3 markdown).
3. Even better, allow power-users to manually edit a dashboard's layout using a markdown or similar editor. This will allow creating rich dashboards which are self-explanatory and possibly include images, "static" tables etc - all in one place.



Discussion:

Resolving patch:
diff --git a/migrations/add_text_to_widgets.py b/migrations/add_text_to_widgets.py
new file mode 100644
--- /dev/null
+++ b/migrations/add_text_to_widgets.py
@@ -0,0 +1,13 @@
+from playhouse.migrate import Migrator
+from redash import db
+from redash import models
+
+
+if __name__ == '__main__':
+    db.connect_db()
+    migrator = Migrator(db.database)
+    with db.database.transaction():
+        migrator.add_column(models.Widget, models.Widget.text, 'text')
+        migrator.set_nullable(models.Widget, models.Widget.visualization, True)
+
+    db.close_db(None)
\ No newline at end of file
diff --git a/redash/models.py b/redash/models.py
--- a/redash/models.py
+++ b/redash/models.py
@@ -255,11 +255,11 @@ def to_dict(self, with_widgets=False):
         if with_widgets:
             widgets = Widget.select(Widget, Visualization, Query, QueryResult, User)\
                 .where(Widget.dashboard == self.id)\
-                .join(Visualization)\
-                .join(Query)\
-                .join(User)\
+                .join(Visualization, join_type=peewee.JOIN_LEFT_OUTER)\
+                .join(Query, join_type=peewee.JOIN_LEFT_OUTER)\
+                .join(User, join_type=peewee.JOIN_LEFT_OUTER)\
                 .switch(Query)\
-                .join(QueryResult)
+                .join(QueryResult, join_type=peewee.JOIN_LEFT_OUTER)
             widgets = {w.id: w.to_dict() for w in widgets}
             widgets_layout = map(lambda row: map(lambda widget_id: widgets.get(widget_id, None), row), layout)
         else:
@@ -324,8 +324,8 @@ def __unicode__(self):
 
 class Widget(BaseModel):
     id = peewee.PrimaryKeyField()
-    visualization = peewee.ForeignKeyField(Visualization, related_name='widgets')
-
+    visualization = peewee.ForeignKeyField(Visualization, related_name='widgets', null=True)
+    text = peewee.TextField(null=True)
     width = peewee.IntegerField()
     options = peewee.TextField()
     dashboard = peewee.ForeignKeyField(Dashboard, related_name='widgets', index=True)
@@ -339,14 +339,19 @@ class Meta:
         db_table = 'widgets'
 
     def to_dict(self):
-        return {
+        d = {
             'id': self.id,
             'width': self.width,
             'options': json.loads(self.options),
-            'visualization': self.visualization.to_dict(),
-            'dashboard_id': self._data['dashboard']
+            'dashboard_id': self._data['dashboard'],
+            'text': self.text
         }
 
+        if self.visualization and self.visualization.id:
+            d['visualization'] = self.visualization.to_dict()
+
+        return d
+
     def __unicode__(self):
         return u"%s" % self.id
 


Test patch:
diff --git a/tests/test_controllers.py b/tests/test_controllers.py
--- a/tests/test_controllers.py
+++ b/tests/test_controllers.py
@@ -182,6 +182,23 @@ def test_create_widget(self):
                            [rv4.json['widget']['id']]])
         self.assertEquals(rv4.json['new_row'], True)
 
+    def test_create_text_widget(self):
+        dashboard = dashboard_factory.create()
+
+        data = {
+            'visualization_id': None,
+            'text': 'Sample text.',
+            'dashboard_id': dashboard.id,
+            'options': {},
+            'width': 2
+        }
+
+        with app.test_client() as c, authenticated_user(c):
+            rv = json_request(c.post, '/api/widgets', data=data)
+
+        self.assertEquals(rv.status_code, 200)
+        self.assertEquals(rv.json['widget']['text'], 'Sample text.')
+
     def test_delete_widget(self):
         widget = widget_factory.create()