Commit d3820e04 authored by Romain Clement's avatar Romain Clement
Browse files

Add standalone chart view from dashboard

parent 3398f2a0
......@@ -5,7 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Add standalone chart view from dashboard
### Changed
- BREAKING: `charts` property is now a dictionary
- BREAKING: removed `alias` property for charts
- BREAKING: dashboard layout is now using chart key identifiers in the `charts` property
- Set mobile layout breakpoint to 800px for better readability
## [0.1.3] - 2021-05-01
......
......@@ -34,16 +34,16 @@ plugins:
title: My Dashboard
description: Showing some nice metrics
layout:
- [note, events-count]
- [note, events-source]
- [analysis-note, events-count]
- [analysis-note, events-source]
charts:
- alias: note
analysis-note:
library: markdown
display: |-
# Analysis notes
> A quick rundown of events statistics and KPIs
- alias: events-count
events-count:
title: Total number of events
db: jobs
query: SELECT count(*) as count FROM events
......@@ -53,7 +53,7 @@ plugins:
prefix:
suffix:
- alias: events-source
events-source:
title: Number of events by source
db: jobs
query: SELECT source, count(*) as count FROM events GROUP BY source ORDER BY count DESC
......@@ -81,7 +81,6 @@ Common chart properties for all chart types:
| Property | Type | Description |
| --------- | -------- | -------------------------------------------------------- |
| `alias` | `string` | Chart identifier for layout (must not contain spaces) |
| `title` | `string` | Chart title |
| `db` | `string` | Database name against which to run the query |
| `query` | `string` | SQL query to run and extract data from |
......@@ -142,7 +141,7 @@ To make use of custom dashboard layout using [CSS Grid Layout](https://developer
define the `layout` array property as a grid / matrix:
- Each entry represents a row of charts
- Each column is referring a chart `alias` property
- Each column is referring a chart by its property name
## Development
......
......@@ -47,7 +47,7 @@ async def dashboard_view(request, datasette):
except KeyError:
raise NotFound(f"Dashboard not found: {slug}")
dbs = set([chart["db"] for chart in dashboard["charts"] if "db" in chart])
dbs = set([chart["db"] for chart in dashboard["charts"].values() if "db" in chart])
for db in dbs:
try:
database = datasette.get_database(db)
......@@ -58,7 +58,41 @@ async def dashboard_view(request, datasette):
return Response.html(
await datasette.render_template(
"dashboard_view.html",
{"dashboard": dashboard},
{"slug": slug, "dashboard": dashboard},
)
)
async def dashboard_chart(request, datasette):
await check_permission_instance(request, datasette)
config = datasette.plugin_config("datasette-dashboards") or {}
slug = urllib.parse.unquote(request.url_vars["slug"])
chart_slug = urllib.parse.unquote(request.url_vars["chart_slug"])
try:
dashboard = config[slug]
except KeyError:
raise NotFound(f"Dashboard not found: {slug}")
try:
chart = dashboard["charts"][chart_slug]
except KeyError:
raise NotFound(f"Chart does not exist: {chart_slug}")
db = chart.get("db")
if db:
database = datasette.get_database(db)
await check_permission_execute_sql(request, datasette, database)
return Response.html(
await datasette.render_template(
"dashboard_chart.html",
{
"slug": slug,
"dashboard": dashboard,
"chart": chart,
},
)
)
......@@ -67,7 +101,8 @@ async def dashboard_view(request, datasette):
def register_routes():
return (
("^/-/dashboards$", dashboard_list),
("^/-/dashboards/(?P<slug>.*)$", dashboard_view),
("^/-/dashboards/(?P<slug>[^/]+)$", dashboard_view),
("^/-/dashboards/(?P<slug>[^/]+)/(?P<chart_slug>[^/]+)$", dashboard_chart),
)
......
{% extends "base.html" %}
{% block title %}{{ chart.title }}{% endblock %}
{% block extra_head %}
{{ super() }}
<link href="{{ urls.static_plugins('datasette_dashboards', 'dashboards.css') }}" rel="stylesheet"/>
{% endblock %}
{% block nav %}
<p class="crumbs">
<a href="{{ urls.instance() }}">home</a> /
<a href="{{ urls.path('-/dashboards') }}">dashboards</a> /
<a href="{{ urls.path('-/dashboards') }}/{{ slug }}">{{ dashboard.title }}</a>
</p>
{{ super() }}
{% endblock %}
{% block body_class %}index{% endblock %}
{% block content %}
<div class="dashboard-header">
<div class="page-header" style="border-color: black">
<h1>{{ dashboard.title }}</h1>
</div>
{% if chart.title %}
<p>{{ chart.title }}</p>
{% endif %}
</div>
{% if chart.db and chart.query %}
<p><a class="not-underlined" title="{{ chart.query }}" href="{{ urls.database(chart.db) }}?{{ {'sql': chart.query}|urlencode|safe }}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<div id="chart" class="dashboard-card-chart">
{% if chart.library == 'markdown' %}
{{ render_markdown(chart.display) }}
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script>
<script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script>
<script type="text/javascript">
{% if chart.library == 'vega' %}
renderVegaChart('#chart', JSON.parse('{{ chart|tojson }}'))
{% elif chart.library == 'metric' %}
renderMetricChart('#chart', JSON.parse('{{ chart|tojson }}'))
{% endif %}
</script>
{% endblock %}
......@@ -20,7 +20,7 @@
{% for slug, dashboard in dashboards.items() %}
<h2 style="padding-left: 10px; border-left: 10px solid">
<a href="{{ urls.path('-/dashboards/' + slug) }}">{{ dashboard.title }}</a>
<a href="{{ urls.path('-/dashboards') }}/{{ slug }}">{{ dashboard.title }}</a>
</h2>
<p>{{ dashboard.description }}</p>
{% endfor %}
......
......@@ -16,9 +16,9 @@
}
{% if dashboard.layout %}
{% for chart in dashboard.charts %}
#card-{{ loop.index }} {
grid-area: {{ chart.alias }};
{% for chart_slug, chart in dashboard.charts.items() %}
#card-{{ chart_slug }} {
grid-area: {{ chart_slug }};
}
{% endfor %}
{% endif %}
......@@ -45,15 +45,15 @@
</div>
<div class="dashboard-grid">
{% for chart in dashboard.charts %}
<div id="card-{{ loop.index }}" class="dashboard-card">
{% for chart_slug, chart in dashboard.charts.items() %}
<div id="card-{{ chart_slug }}" class="dashboard-card">
{% if chart.library != 'markdown' %}
<div class="dashboard-card-title">
<p><a href="/{{ chart.db }}?sql={{ chart.query }}">{{ chart.title }}</a></p>
<p><a href="{{ urls.path('-/dashboards') }}/{{ slug }}/{{ chart_slug }}">{{ chart.title }}</a></p>
</div>
{% endif %}
<div id="chart-{{ loop.index }}" class="dashboard-card-chart">
<div id="chart-{{ chart_slug }}" class="dashboard-card-chart">
{% if chart.library == 'markdown' %}
{{ render_markdown(chart.display) }}
{% endif %}
......@@ -67,11 +67,11 @@
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script>
<script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script>
<script type="text/javascript">
{% for chart in dashboard.charts %}
{% for chart_slug, chart in dashboard.charts.items() %}
{% if chart.library == 'vega' %}
renderVegaChart('#chart-{{ loop.index }}', JSON.parse('{{ chart|tojson }}'))
renderVegaChart('#chart-{{ chart_slug }}', JSON.parse('{{ chart|tojson }}'))
{% elif chart.library == 'metric' %}
renderMetricChart('#chart-{{ loop.index }}', JSON.parse('{{ chart|tojson }}'))
renderMetricChart('#chart-{{ chart_slug }}', JSON.parse('{{ chart|tojson }}'))
{% endif %}
{% endfor %}
</script>
......
......@@ -12,7 +12,7 @@ plugins:
- [analysis-note, offers-day, offers-day, offers-count]
- [analysis-note, offers-source, offers-day-source, offers-region]
charts:
- alias: analysis-note
analysis-note:
library: markdown
display: |-
# Analysis details
......@@ -45,7 +45,7 @@ plugins:
ORDER BY day
```
- alias: offers-count
offers-count:
title: Total number of offers
db: jobs
query: SELECT count(*) as count FROM offers_view;
......@@ -55,7 +55,7 @@ plugins:
prefix:
suffix: " offers"
- alias: offers-day
offers-day:
title: Number of offers by day
db: jobs
query: SELECT date(date) as day, count(*) as count FROM offers_view GROUP BY day ORDER BY day
......@@ -66,7 +66,7 @@ plugins:
x: { field: day, type: temporal }
y: { field: count, type: quantitative }
- alias: offers-source
offers-source:
title: Number of offers by source
db: jobs
query: SELECT source, count(*) as count FROM offers_view GROUP BY source ORDER BY count DESC
......@@ -84,7 +84,7 @@ plugins:
condition: { param: highlight, value: 1 }
value: 0.2
- alias: offers-day-source
offers-day-source:
title: Offers by day by source
db: jobs
query: SELECT date(date) as day, source, count(*) as count FROM offers_view GROUP BY day, source ORDER BY day
......@@ -103,7 +103,7 @@ plugins:
condition: { param: highlight, value: 1 }
value: 0.2
- alias: offers-region
offers-region:
title: Offers by region
db: jobs
query: SELECT region, count(*) as count FROM offers_view GROUP BY region ORDER BY count DESC
......
......@@ -35,9 +35,8 @@ def datasette_metadata():
"job-dashboard": {
"title": "Job dashboard",
"description": "Gathering metrics about jobs",
"charts": [
{
"alias": "analysis-note",
"charts": {
"analysis-note": {
"library": "markdown",
"display": """
# Analysis details
......@@ -71,8 +70,7 @@ def datasette_metadata():
```
""",
},
{
"alias": "offers-count",
"offers-count": {
"title": "Total number of offers",
"db": "test",
"query": "SELECT count(*) as count FROM offers_view;",
......@@ -83,8 +81,7 @@ def datasette_metadata():
"suffix": " offers",
},
},
{
"alias": "offers-day",
"offers-day": {
"title": "Number of offers by day",
"db": "test",
"query": "SELECT date(date) as day, count(*) as count FROM jobs GROUP BY day ORDER BY day",
......@@ -100,8 +97,7 @@ def datasette_metadata():
},
},
},
{
"alias": "offers-source",
"offers-source": {
"title": "Number of offers by source",
"db": "test",
"query": "SELECT source, count(*) as count FROM jobs GROUP BY source ORDER BY count DESC",
......@@ -117,7 +113,7 @@ def datasette_metadata():
},
},
},
],
},
}
}
}
......
import pytest
@pytest.mark.asyncio
async def test_dashboard_chart(datasette):
dashboards = datasette._metadata["plugins"]["datasette-dashboards"]
for slug, dashboard in dashboards.items():
for chart_slug, chart in dashboard["charts"].items():
props = chart.keys()
response = await datasette.client.get(f"/-/dashboards/{slug}/{chart_slug}")
assert response.status_code == 200
assert f'<h1>{dashboard["title"]}</h1>' in response.text
if "title" in props:
assert f'<p>{chart["title"]}</p>' in response.text
if "db" in props and "query" in props:
assert f'<a href="/{chart["db"]}?sql={chart["query"]}">'
assert "View and edit SQL"
@pytest.mark.asyncio
async def test_dashboard_chart_unknown_dashboard(datasette):
dashboards = datasette._metadata["plugins"]["datasette-dashboards"]
dashboard = list(dashboards.values())[0]
chart_slug = list(dashboard["charts"].items())[0][0]
response = await datasette.client.get(
f"/-/dashboards/unknown-dashboard/{chart_slug}"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_dashboard_chart_unknown_chart(datasette):
dashboards = datasette._metadata["plugins"]["datasette-dashboards"]
slug = list(dashboards.keys())[0]
response = await datasette.client.get(f"/-/dashboards/{slug}/unknown-chart")
assert response.status_code == 404
......@@ -14,24 +14,24 @@ async def test_dashboard_views(datasette):
assert "grid-template-areas" not in response.text
assert "grid-area:" not in response.text
for index, chart in enumerate(dashboard["charts"]):
for chart_slug, chart in dashboard["charts"].items():
assert (
f'<div id="chart-{index + 1}" class="dashboard-card-chart">'
f'<div id="chart-{chart_slug}" class="dashboard-card-chart">'
in response.text
)
if chart["library"] == "vega":
assert (
f'<p><a href="/{chart["db"]}?sql={chart["query"]}">{chart["title"]}</a></p>'
f'<p><a href="/-/dashboards/{slug}/{chart_slug}">{chart["title"]}</a></p>'
in response.text
)
assert f"renderVegaChart('#chart-{index + 1}', " in response.text
assert f"renderVegaChart('#chart-{chart_slug}', " in response.text
elif chart["library"] == "metric":
assert (
f'<p><a href="/{chart["db"]}?sql={chart["query"]}">{chart["title"]}</a></p>'
f'<p><a href="/-/dashboards/{slug}/{chart_slug}">{chart["title"]}</a></p>'
in response.text
)
assert f"renderMetricChart('#chart-{index + 1}', " in response.text
assert f"renderMetricChart('#chart-{chart_slug}', " in response.text
@pytest.mark.asyncio
......@@ -71,9 +71,9 @@ async def test_dashboard_view_unknown_chart_db(datasette):
original_metadata = datasette._metadata
try:
metadata = copy.deepcopy(datasette._metadata)
metadata["plugins"]["datasette-dashboards"]["job-dashboard"]["charts"][0][
"db"
] = "unknown_db"
metadata["plugins"]["datasette-dashboards"]["job-dashboard"]["charts"][
"offers-count"
]["db"] = "unknown_db"
datasette._metadata = metadata
response = await datasette.client.get("/-/dashboards/job-dashboard")
assert response.status_code == 404
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment