From 2e836f72d9a4e61341d75ea48e88314e0d006b65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 28 Aug 2018 03:03:01 -0700 Subject: [PATCH] render_cell(value, column, table, database, datasette) The render_cell plugin hook previously was only passed value. It is now passed (value, column, table, database, datasette). --- datasette/hookspecs.py | 2 +- datasette/utils.py | 4 ++++ datasette/views/base.py | 10 ++++++++-- datasette/views/table.py | 8 +++++++- docs/plugins.rst | 34 ++++++++++++++++++++++++++++------ tests/fixtures.py | 39 +++++++++++++++++++++++++++++++++------ tests/test_api.py | 26 +++++++++++++++++++++++--- tests/test_csv.py | 1 + tests/test_html.py | 8 ++++---- tests/test_plugins.py | 20 ++++++++++++++++++-- 10 files changed, 127 insertions(+), 25 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 9b34f8a0..95cae450 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -36,5 +36,5 @@ def publish_subcommand(publish): @hookspec(firstresult=True) -def render_cell(value): +def render_cell(value, column, table, database, datasette): "Customize rendering of HTML table cell values" diff --git a/datasette/utils.py b/datasette/utils.py index 568c9abc..1033535b 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -54,6 +54,10 @@ class Results: self.truncated = truncated self.description = description + @property + def columns(self): + return [d[0] for d in self.description] + def __iter__(self): return iter(self.rows) diff --git a/datasette/views/base.py b/datasette/views/base.py index 422925d8..0ddcaca9 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -501,10 +501,16 @@ class BaseView(RenderMixin): display_rows = [] for row in results.rows: display_row = [] - for value in row: + for column, value in zip(results.columns, row): display_value = value # Let the plugins have a go - plugin_value = pm.hook.render_cell(value=value) + plugin_value = pm.hook.render_cell( + value=value, + column=column, + table=None, + database=name, + datasette=self.ds, + ) if plugin_value is not None: display_value = plugin_value else: diff --git a/datasette/views/table.py b/datasette/views/table.py index bf6f2355..a91ddc19 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -167,7 +167,13 @@ class RowTableShared(BaseView): continue # First let the plugins have a go - plugin_display_value = pm.hook.render_cell(value=value) + plugin_display_value = pm.hook.render_cell( + value=value, + column=column, + table=table, + database=database, + datasette=self.ds, + ) if plugin_display_value is not None: display_value = plugin_display_value elif isinstance(value, dict): diff --git a/docs/plugins.rst b/docs/plugins.rst index d4fc0b8c..8872a4ac 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -217,9 +217,16 @@ Now that ``datasette-cluster-map`` plugin configuration will apply to every tabl Plugin hooks ------------ -Datasette will eventually have many more plugin hooks. You can track and -contribute to their development in `issue #14 -`_. +When you implement a plugin hook you can accept any or all of the parameters that are documented as being passed to that hook. For example, you can implement a ``render_cell`` plugin hook like this even though the hook definition defines more parameters than just ``value`` and ``column``: + +.. code-block:: python + + @hookimpl + def render_cell(value, column): + if column == "stars": + return "*" * int(value) + +The full list of available plugin hooks is as follows. prepare_connection(conn) ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -333,12 +340,25 @@ and ``heroku`` subcommands, so you can read `their source `_ to see examples of this hook in action. -render_cell(value) -~~~~~~~~~~~~~~~~~~ +render_cell(value, column, table, database, datasette) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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. +``value`` - string, integer or None + The value that was loaded from the database + +``column`` - string + The name of the column being rendered + +``table`` - string + The name of the table + +``database`` - string + The name of the database + +``datasette`` - Datasette instance + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)`` 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. @@ -346,6 +366,8 @@ 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. +Datasette will loop through all available ``render_cell`` hooks and display the value returned by the first one that does not return ``None``. + 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"} diff --git a/tests/fixtures.py b/tests/fixtures.py index bdfffbaf..77e06db1 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -146,6 +146,12 @@ METADATA = { 'simple_primary_key': { 'description_html': 'Simple primary key', 'title': 'This HTML is escaped', + "plugins": { + "name-of-plugin": { + "depth": "table", + "special": "this-is-simple_primary_key" + } + } }, 'sortable': { 'sortable_columns': [ @@ -199,6 +205,7 @@ METADATA = { PLUGIN1 = ''' from datasette import hookimpl import pint +import json ureg = pint.UnitRegistry() @@ -226,7 +233,6 @@ def extra_js_urls(): @hookimpl def extra_body_script(template, database, table, datasette): - import json return 'var extra_body_script = {};'.format( json.dumps({ "template": template, @@ -239,6 +245,23 @@ def extra_body_script(template, database, table, datasette): ) }) ) + + +@hookimpl +def render_cell(value, column, table, database, datasette): + # Render some debug output in cell with value RENDER_CELL_DEMO + if value != "RENDER_CELL_DEMO": + return None + return json.dumps({ + "column": column, + "table": table, + "database": database, + "config": datasette.plugin_config( + "name-of-plugin", + database=database, + table=table, + ) + }) ''' PLUGIN2 = ''' @@ -256,7 +279,7 @@ def extra_js_urls(): @hookimpl -def render_cell(value): +def render_cell(value, database): # Render {"href": "...", "label": "..."} as link if not isinstance(value, str): return None @@ -277,10 +300,13 @@ def render_cell(value): or href.startswith("https://") ): return None - return jinja2.Markup('{label}'.format( - href=jinja2.escape(data["href"]), - label=jinja2.escape(data["label"] or "") or " " - )) + return jinja2.Markup( + '{label}'.format( + database=database, + href=jinja2.escape(data["href"]), + label=jinja2.escape(data["label"] or "") or " " + ) + ) ''' TABLES = ''' @@ -487,6 +513,7 @@ VALUES INSERT INTO simple_primary_key VALUES (1, 'hello'); INSERT INTO simple_primary_key VALUES (2, 'world'); INSERT INTO simple_primary_key VALUES (3, ''); +INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO'); INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world'); INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2'); diff --git a/tests/test_api.py b/tests/test_api.py index f76795fe..c1f22733 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -242,7 +242,7 @@ def test_database_page(app_client): }, { 'columns': ['id', 'content'], 'name': 'simple_primary_key', - 'count': 3, + 'count': 4, 'hidden': False, 'foreign_keys': { 'incoming': [{ @@ -383,7 +383,8 @@ def test_custom_sql(app_client): assert [ {'content': 'hello'}, {'content': 'world'}, - {'content': ''} + {'content': ''}, + {'content': 'RENDER_CELL_DEMO'} ] == data['rows'] assert ['content'] == data['columns'] assert 'fixtures' == data['database'] @@ -457,6 +458,9 @@ def test_table_json(app_client): }, { 'id': '3', 'content': '', + }, { + 'id': '4', + 'content': 'RENDER_CELL_DEMO', }] @@ -490,6 +494,7 @@ def test_table_shape_arrays(app_client): ['1', 'hello'], ['2', 'world'], ['3', ''], + ['4', 'RENDER_CELL_DEMO'], ] == response.json['rows'] @@ -500,7 +505,7 @@ def test_table_shape_arrayfirst(app_client): '_shape': 'arrayfirst' }) ) - assert ['hello', 'world', ''] == response.json + assert ['hello', 'world', '', 'RENDER_CELL_DEMO'] == response.json def test_table_shape_objects(app_client): @@ -516,6 +521,9 @@ def test_table_shape_objects(app_client): }, { 'id': '3', 'content': '', + }, { + 'id': '4', + 'content': 'RENDER_CELL_DEMO', }] == response.json['rows'] @@ -532,6 +540,9 @@ def test_table_shape_array(app_client): }, { 'id': '3', 'content': '', + }, { + 'id': '4', + 'content': 'RENDER_CELL_DEMO', }] == response.json @@ -563,6 +574,10 @@ def test_table_shape_object(app_client): '3': { 'id': '3', 'content': '', + }, + '4': { + 'id': '4', + 'content': 'RENDER_CELL_DEMO', } } == response.json @@ -826,6 +841,7 @@ def test_searchable_invalid_column(app_client): ('/fixtures/simple_primary_key.json?content__contains=o', [ ['1', 'hello'], ['2', 'world'], + ['4', 'RENDER_CELL_DEMO'], ]), ('/fixtures/simple_primary_key.json?content__exact=', [ ['3', ''], @@ -833,6 +849,7 @@ def test_searchable_invalid_column(app_client): ('/fixtures/simple_primary_key.json?content__not=world', [ ['1', 'hello'], ['3', ''], + ['4', 'RENDER_CELL_DEMO'], ]), ]) def test_table_filter_queries(app_client, path, expected_rows): @@ -866,6 +883,9 @@ def test_view(app_client): }, { 'upper_content': '', 'content': '', + }, { + 'upper_content': 'RENDER_CELL_DEMO', + 'content': 'RENDER_CELL_DEMO', }] diff --git a/tests/test_csv.py b/tests/test_csv.py index 398dbd1f..357838f6 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -8,6 +8,7 @@ EXPECTED_TABLE_CSV = '''id,content 1,hello 2,world 3, +4,RENDER_CELL_DEMO '''.replace('\n', '\r\n') EXPECTED_CUSTOM_CSV = '''content diff --git a/tests/test_html.py b/tests/test_html.py index 4a792b62..47738604 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -372,7 +372,7 @@ def test_css_classes_on_body(app_client, path, expected_classes): def test_table_html_simple_primary_key(app_client): - response = app_client.get('/fixtures/simple_primary_key') + response = app_client.get('/fixtures/simple_primary_key?_size=3') assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') assert table['class'] == ['rows-and-columns'] @@ -381,7 +381,7 @@ def test_table_html_simple_primary_key(app_client): for expected_col, th in zip(('content',), ths[1:]): a = th.find('a') assert expected_col == a.string - assert a['href'].endswith('/simple_primary_key?_sort={}'.format( + assert a['href'].endswith('/simple_primary_key?_size=3&_sort={}'.format( expected_col )) assert ['nofollow'] == a['rel'] @@ -613,13 +613,13 @@ def test_compound_primary_key_with_foreign_key_references(app_client): def test_view_html(app_client): - response = app_client.get("/fixtures/simple_view") + response = app_client.get("/fixtures/simple_view?_size=3") assert response.status == 200 table = Soup(response.body, "html.parser").find("table") ths = table.select("thead th") assert 2 == len(ths) assert ths[0].find("a") is not None - assert ths[0].find("a")["href"].endswith("/simple_view?_sort=content") + assert ths[0].find("a")["href"].endswith("/simple_view?_size=3&_sort=content") assert ths[0].find("a").string.strip() == "content" assert ths[1].find("a") is None assert ths[1].string.strip() == "upper_content" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e312fe58..0cbb920b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -72,7 +72,7 @@ def test_plugins_with_duplicate_js_urls(app_client): ) -def test_plugins_render_cell(app_client): +def test_plugins_render_cell_link_from_json(app_client): sql = """ select '{"href": "http://example.com/", "label":"Example"}' """.strip() @@ -86,9 +86,25 @@ def test_plugins_render_cell(app_client): a = td.find("a") assert a is not None, str(a) assert a.attrs["href"] == "http://example.com/" + assert a.attrs["data-database"] == "fixtures" assert a.text == "Example" +def test_plugins_render_cell_demo(app_client): + response = app_client.get("/fixtures/simple_primary_key?id=4") + soup = Soup(response.body, "html.parser") + td = soup.find("td", {"class": "col-content"}) + assert { + "column": "content", + "table": "simple_primary_key", + "database": "fixtures", + "config": { + "depth": "table", + "special": "this-is-simple_primary_key" + } + } == json.loads(td.string) + + def test_plugin_config(app_client): assert {"depth": "table"} == app_client.ds.plugin_config( "name-of-plugin", database="fixtures", table="sortable" @@ -138,7 +154,7 @@ def test_plugin_config(app_client): ), ], ) -def test_extra_body_script(app_client, path, expected_extra_body_script): +def test_plugins_extra_body_script(app_client, path, expected_extra_body_script): r = re.compile(r"") json_data = r.search(app_client.get(path).body.decode("utf8")).group(1) actual_data = json.loads(json_data)