From 97496d5a672c78271735dd77abde3248eea8b967 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 21 Dec 2025 19:52:49 -0800 Subject: [PATCH] ?_extra=render_cells for tables, refs #2619 --- datasette/views/table.py | 31 ++++++++++++++++++++ tests/test_table_api.py | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/datasette/views/table.py b/datasette/views/table.py index 007c0c85..c8f209d6 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1492,6 +1492,36 @@ async def table_view_data( async def extra_display_rows(run_display_columns_and_rows): return run_display_columns_and_rows["rows"] + async def extra_render_cells(): + "Rendered HTML for each cell using the render_cell plugin hook" + columns = [col[0] for col in results.description] + rendered_rows = [] + for row in rows: + rendered_row = {} + for value, column in zip(row, columns): + # Call render_cell plugin hook + plugin_display_value = None + for candidate in pm.hook.render_cell( + row=row, + value=value, + column=column, + table=table_name, + database=database_name, + datasette=datasette, + request=request, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break + if plugin_display_value: + rendered_row[column] = str(plugin_display_value) + else: + # Default: convert value to string + rendered_row[column] = "" if value is None else str(value) + rendered_rows.append(rendered_row) + return rendered_rows + async def extra_query(): "Details of the underlying SQL query" return { @@ -1678,6 +1708,7 @@ async def table_view_data( run_display_columns_and_rows, extra_display_columns, extra_display_rows, + extra_render_cells, extra_debug, extra_request, extra_query, diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 653679e4..d5a8ca41 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1383,3 +1383,65 @@ async def test_table_extras(ds_client, extra, expected_json): ) assert response.status_code == 200 assert response.json() == expected_json + + +@pytest.mark.asyncio +async def test_extra_render_cells(): + """Test that _extra=render_cells returns rendered HTML from render_cell plugin hook""" + from datasette import hookimpl + from datasette.app import Datasette + + class TestRenderCellPlugin: + __name__ = "TestRenderCellPlugin" + + @hookimpl + def render_cell(self, value, column, table, database): + # Only modify cells in our test table + if table == "test_render" and column == "name": + return f"{value}" + return None + + ds = Datasette(memory=True) + await ds.invoke_startup() + db = ds.add_memory_database("test") + await db.execute_write( + "create table test_render (id integer primary key, name text)" + ) + await db.execute_write("insert into test_render values (1, 'Alice')") + await db.execute_write("insert into test_render values (2, 'Bob')") + + # Register our test plugin + ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin") + + try: + # Request with _extra=render_cells + response = await ds.client.get("/test/test_render.json?_extra=render_cells") + assert response.status_code == 200 + data = response.json() + + # Verify the response structure + assert "render_cells" in data + assert "rows" in data + + # render_cells should be a list of rows, each row being a dict of column -> rendered HTML + render_cells = data["render_cells"] + assert len(render_cells) == 2 + + # First row: id=1, name='Alice' + # The 'name' column should be rendered by our plugin as Alice + assert render_cells[0]["name"] == "Alice" + # The 'id' column should use default rendering (just the value as string) + assert render_cells[0]["id"] == "1" + + # Second row: id=2, name='Bob' + assert render_cells[1]["name"] == "Bob" + assert render_cells[1]["id"] == "2" + + # The regular rows should still contain raw values + assert data["rows"] == [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + ] + + finally: + ds.pm.unregister(name="TestRenderCellPlugin")