mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
render_cell(value) plugin hook, closes #352
New plugin hook for customizing the way cells values are rendered in HTML. The first full example of this hook in use is https://github.com/simonw/datasette-json-html
This commit is contained in:
parent
295d005ca4
commit
4ac9132240
10 changed files with 150 additions and 30 deletions
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import click
|
import click
|
||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib
|
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -14,7 +13,6 @@ from concurrent import futures
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
import pluggy
|
|
||||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
||||||
from sanic import Sanic, response
|
from sanic import Sanic, response
|
||||||
from sanic.exceptions import InvalidUsage, NotFound
|
from sanic.exceptions import InvalidUsage, NotFound
|
||||||
|
|
@ -28,7 +26,6 @@ from .views.index import IndexView
|
||||||
from .views.special import JsonDataView
|
from .views.special import JsonDataView
|
||||||
from .views.table import RowView, TableView
|
from .views.table import RowView, TableView
|
||||||
|
|
||||||
from . import hookspecs
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
InterruptedError,
|
InterruptedError,
|
||||||
Results,
|
Results,
|
||||||
|
|
@ -40,26 +37,13 @@ from .utils import (
|
||||||
to_css_class
|
to_css_class
|
||||||
)
|
)
|
||||||
from .inspect import inspect_hash, inspect_views, inspect_tables
|
from .inspect import inspect_hash, inspect_views, inspect_tables
|
||||||
|
from .plugins import pm
|
||||||
from .version import __version__
|
from .version import __version__
|
||||||
|
|
||||||
default_plugins = (
|
|
||||||
"datasette.publish.heroku",
|
|
||||||
"datasette.publish.now",
|
|
||||||
)
|
|
||||||
|
|
||||||
app_root = Path(__file__).parent.parent
|
app_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
pm = pluggy.PluginManager("datasette")
|
|
||||||
pm.add_hookspecs(hookspecs)
|
|
||||||
pm.load_setuptools_entrypoints("datasette")
|
|
||||||
|
|
||||||
# Load default plugins
|
|
||||||
for plugin in default_plugins:
|
|
||||||
mod = importlib.import_module(plugin)
|
|
||||||
pm.register(mod, plugin)
|
|
||||||
|
|
||||||
|
|
||||||
ConfigOption = collections.namedtuple(
|
ConfigOption = collections.namedtuple(
|
||||||
"ConfigOption", ("name", "default", "help")
|
"ConfigOption", ("name", "default", "help")
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,8 @@ def extra_js_urls():
|
||||||
@hookspec
|
@hookspec
|
||||||
def publish_subcommand(publish):
|
def publish_subcommand(publish):
|
||||||
"Subcommands for 'datasette publish'"
|
"Subcommands for 'datasette publish'"
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec(firstresult=True)
|
||||||
|
def render_cell(value):
|
||||||
|
"Customize rendering of HTML table cell values"
|
||||||
|
|
|
||||||
17
datasette/plugins.py
Normal file
17
datasette/plugins.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import importlib
|
||||||
|
import pluggy
|
||||||
|
from . import hookspecs
|
||||||
|
|
||||||
|
default_plugins = (
|
||||||
|
"datasette.publish.heroku",
|
||||||
|
"datasette.publish.now",
|
||||||
|
)
|
||||||
|
|
||||||
|
pm = pluggy.PluginManager("datasette")
|
||||||
|
pm.add_hookspecs(hookspecs)
|
||||||
|
pm.load_setuptools_entrypoints("datasette")
|
||||||
|
|
||||||
|
# Load default plugins
|
||||||
|
for plugin in default_plugins:
|
||||||
|
mod = importlib.import_module(plugin)
|
||||||
|
pm.register(mod, plugin)
|
||||||
|
|
@ -16,7 +16,6 @@ import shutil
|
||||||
import urllib
|
import urllib
|
||||||
import numbers
|
import numbers
|
||||||
|
|
||||||
|
|
||||||
# From https://www.sqlite.org/lang_keywords.html
|
# From https://www.sqlite.org/lang_keywords.html
|
||||||
reserved_words = set((
|
reserved_words = set((
|
||||||
'abort action add after all alter analyze and as asc attach autoincrement '
|
'abort action add after all alter analyze and as asc attach autoincrement '
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from sanic.exceptions import NotFound
|
||||||
from sanic.views import HTTPMethodView
|
from sanic.views import HTTPMethodView
|
||||||
|
|
||||||
from datasette import __version__
|
from datasette import __version__
|
||||||
|
from datasette.plugins import pm
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
InterruptedError,
|
InterruptedError,
|
||||||
|
|
@ -493,14 +494,19 @@ class BaseView(RenderMixin):
|
||||||
display_row = []
|
display_row = []
|
||||||
for value in row:
|
for value in row:
|
||||||
display_value = value
|
display_value = value
|
||||||
if value in ("", None):
|
# Let the plugins have a go
|
||||||
display_value = jinja2.Markup(" ")
|
plugin_value = pm.hook.render_cell(value=value)
|
||||||
elif is_url(str(value).strip()):
|
if plugin_value is not None:
|
||||||
display_value = jinja2.Markup(
|
display_value = plugin_value
|
||||||
'<a href="{url}">{url}</a>'.format(
|
else:
|
||||||
url=jinja2.escape(value.strip())
|
if value in ("", None):
|
||||||
|
display_value = jinja2.Markup(" ")
|
||||||
|
elif is_url(str(display_value).strip()):
|
||||||
|
display_value = jinja2.Markup(
|
||||||
|
'<a href="{url}">{url}</a>'.format(
|
||||||
|
url=jinja2.escape(value.strip())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
display_row.append(display_value)
|
display_row.append(display_value)
|
||||||
display_rows.append(display_row)
|
display_rows.append(display_row)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import jinja2
|
||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.request import RequestParameters
|
from sanic.request import RequestParameters
|
||||||
|
|
||||||
|
from datasette.plugins import pm
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomRow,
|
CustomRow,
|
||||||
Filters,
|
Filters,
|
||||||
|
|
@ -22,7 +23,6 @@ from datasette.utils import (
|
||||||
urlsafe_components,
|
urlsafe_components,
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .base import BaseView, DatasetteError, ureg
|
from .base import BaseView, DatasetteError, ureg
|
||||||
|
|
||||||
LINK_WITH_LABEL = '<a href="/{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
LINK_WITH_LABEL = '<a href="/{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
||||||
|
|
@ -166,7 +166,11 @@ class RowTableShared(BaseView):
|
||||||
# already shown in the link column.
|
# already shown in the link column.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(value, dict):
|
# First let the plugins have a go
|
||||||
|
plugin_display_value = pm.hook.render_cell(value=value)
|
||||||
|
if plugin_display_value is not None:
|
||||||
|
display_value = plugin_display_value
|
||||||
|
elif isinstance(value, dict):
|
||||||
# It's an expanded foreign key - display link to other row
|
# It's an expanded foreign key - display link to other row
|
||||||
label = value["label"]
|
label = value["label"]
|
||||||
value = value["value"]
|
value = value["value"]
|
||||||
|
|
|
||||||
|
|
@ -267,3 +267,56 @@ command. Datasette uses this hook internally to implement the default ``now``
|
||||||
and ``heroku`` subcommands, so you can read
|
and ``heroku`` subcommands, so you can read
|
||||||
`their source <https://github.com/simonw/datasette/tree/master/datasette/publish>`_
|
`their source <https://github.com/simonw/datasette/tree/master/datasette/publish>`_
|
||||||
to see examples of this hook in action.
|
to see examples of this hook in action.
|
||||||
|
|
||||||
|
render_cell(value)
|
||||||
|
~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Lets you customize the display of values within table cells in the HTML table view.
|
||||||
|
|
||||||
|
``value`` is the value that was loaded from the database.
|
||||||
|
|
||||||
|
If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.
|
||||||
|
|
||||||
|
If the hook returns a string, that string will be rendered in the table cell.
|
||||||
|
|
||||||
|
If you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object.
|
||||||
|
|
||||||
|
Here is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format::
|
||||||
|
|
||||||
|
{"href": "https://www.example.com/", "label": "Name"}
|
||||||
|
|
||||||
|
If the value matches that pattern, the plugin returns an HTML link element:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
import jinja2
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def render_cell(value):
|
||||||
|
# Render {"href": "...", "label": "..."} as link
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped.startswith("{") and stripped.endswith("}"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
if set(data.keys()) != {"href", "label"}:
|
||||||
|
return None
|
||||||
|
href = data["href"]
|
||||||
|
if not (
|
||||||
|
href.startswith("/") or href.startswith("http://")
|
||||||
|
or href.startswith("https://")
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return jinja2.Markup('<a href="{href}">{label}</a>'.format(
|
||||||
|
href=jinja2.escape(data["href"]),
|
||||||
|
label=jinja2.escape(data["label"] or "") or " "
|
||||||
|
))
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,8 @@ def extra_js_urls():
|
||||||
|
|
||||||
PLUGIN2 = '''
|
PLUGIN2 = '''
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
import jinja2
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -216,6 +218,34 @@ def extra_js_urls():
|
||||||
'url': 'https://example.com/jquery.js',
|
'url': 'https://example.com/jquery.js',
|
||||||
'sri': 'SRIHASH',
|
'sri': 'SRIHASH',
|
||||||
}, 'https://example.com/plugin2.js']
|
}, 'https://example.com/plugin2.js']
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def render_cell(value):
|
||||||
|
# Render {"href": "...", "label": "..."} as link
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped.startswith("{") and stripped.endswith("}"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
if set(data.keys()) != {"href", "label"}:
|
||||||
|
return None
|
||||||
|
href = data["href"]
|
||||||
|
if not (
|
||||||
|
href.startswith("/") or href.startswith("http://")
|
||||||
|
or href.startswith("https://")
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return jinja2.Markup('<a href="{href}">{label}</a>'.format(
|
||||||
|
href=jinja2.escape(data["href"]),
|
||||||
|
label=jinja2.escape(data["label"] or "") or " "
|
||||||
|
))
|
||||||
'''
|
'''
|
||||||
|
|
||||||
TABLES = '''
|
TABLES = '''
|
||||||
|
|
@ -363,9 +393,12 @@ INSERT INTO "searchable_fts" (rowid, text1, text2, [name with . and spaces])
|
||||||
CREATE TABLE [select] (
|
CREATE TABLE [select] (
|
||||||
[group] text,
|
[group] text,
|
||||||
[having] text,
|
[having] text,
|
||||||
[and] text
|
[and] text,
|
||||||
|
[json] text
|
||||||
|
);
|
||||||
|
INSERT INTO [select] VALUES ('group', 'having', 'and',
|
||||||
|
'{"href": "http://example.com/", "label":"Example"}'
|
||||||
);
|
);
|
||||||
INSERT INTO [select] VALUES ('group', 'having', 'and');
|
|
||||||
|
|
||||||
CREATE TABLE infinity (
|
CREATE TABLE infinity (
|
||||||
value REAL
|
value REAL
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ def test_database_page(app_client):
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
'columns': ['group', 'having', 'and'],
|
'columns': ['group', 'having', 'and', 'json'],
|
||||||
'name': 'select',
|
'name': 'select',
|
||||||
'count': 1,
|
'count': 1,
|
||||||
'hidden': False,
|
'hidden': False,
|
||||||
|
|
@ -599,6 +599,7 @@ def test_table_with_reserved_word_name(app_client):
|
||||||
'group': 'group',
|
'group': 'group',
|
||||||
'having': 'having',
|
'having': 'having',
|
||||||
'and': 'and',
|
'and': 'and',
|
||||||
|
'json': '{"href": "http://example.com/", "label":"Example"}'
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from .fixtures import ( # noqa
|
||||||
app_client,
|
app_client,
|
||||||
)
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
|
||||||
def test_plugins_dir_plugin(app_client):
|
def test_plugins_dir_plugin(app_client):
|
||||||
|
|
@ -67,3 +68,20 @@ def test_plugins_with_duplicate_js_urls(app_client):
|
||||||
) < srcs.index(
|
) < srcs.index(
|
||||||
'https://example.com/plugin2.js'
|
'https://example.com/plugin2.js'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugins_render_cell(app_client):
|
||||||
|
sql = """
|
||||||
|
select '{"href": "http://example.com/", "label":"Example"}'
|
||||||
|
""".strip()
|
||||||
|
path = "/fixtures?" + urllib.parse.urlencode({
|
||||||
|
"sql": sql,
|
||||||
|
})
|
||||||
|
response = app_client.get(path)
|
||||||
|
td = Soup(
|
||||||
|
response.body, "html.parser"
|
||||||
|
).find("table").find("tbody").find("td")
|
||||||
|
a = td.find("a")
|
||||||
|
assert a is not None, str(a)
|
||||||
|
assert a.attrs["href"] == "http://example.com/"
|
||||||
|
assert a.text == "Example"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue