diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py index 6def3698..4d419be7 100644 --- a/datasette/default_column_types.py +++ b/datasette/default_column_types.py @@ -5,6 +5,7 @@ import markupsafe from datasette import hookimpl from datasette.column_types import ColumnType, SQLiteType +from datasette.utils import truncate_url _HTTP_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE) @@ -23,14 +24,17 @@ class UrlColumnType(ColumnType): description = "URL" sqlite_types = (SQLiteType.TEXT,) - async def render_cell(self, value, column, table, database, datasette, request): + async def render_cell( + self, value, column, table, database, datasette, request, truncate_cells=0 + ): if not value or not isinstance(value, str): return None normalized = _normalize_http_url(value) if normalized is None: return markupsafe.escape(value.strip()) escaped = markupsafe.escape(normalized) - return markupsafe.Markup(f'{escaped}') + link_text = markupsafe.escape(truncate_url(normalized, truncate_cells)) + return markupsafe.Markup(f'{link_text}') async def validate(self, value, datasette): if value is None or value == "": diff --git a/datasette/views/table.py b/datasette/views/table.py index 0a908c69..236bc3fe 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1,4 +1,5 @@ import asyncio +import inspect import itertools import json import time @@ -666,6 +667,13 @@ async def display_columns_and_rows( # Look up column types for this table column_types_map = await datasette.get_column_types(database_name, table_name) + # Column types can opt in to this page's truncation length by accepting + # a truncate_cells argument to their render_cell() method + truncate_cells_columns = { + column + for column, ct in column_types_map.items() + if "truncate_cells" in inspect.signature(ct.render_cell).parameters + } column_details = { col.name: col for col in await db.table_column_details(table_name) @@ -796,6 +804,9 @@ async def display_columns_and_rows( plugin_display_value = None ct = column_types_map.get(column) if ct: + kwargs = {} + if column in truncate_cells_columns: + kwargs["truncate_cells"] = truncate_cells candidate = await ct.render_cell( value=value, column=column, @@ -803,6 +814,7 @@ async def display_columns_and_rows( database=database_name, datasette=datasette, request=request, + **kwargs, ) if candidate is not None: plugin_display_value = candidate diff --git a/tests/test_column_types.py b/tests/test_column_types.py index d8dfc627..5c2090a7 100644 --- a/tests/test_column_types.py +++ b/tests/test_column_types.py @@ -914,6 +914,90 @@ async def test_html_table_page_rendering(ds_ct): assert 'href="https://example.com"' in html +# --- url column type link text truncation, refs #1805 --- + +LONG_URL = ( + "https://images.openfoodfacts.org/images/products/000/000/000/088/" + "nutrition_fr.5.200.jpg" +) + + +@pytest.fixture +def ds_long_url(tmp_path_factory): + db_path = str(tmp_path_factory.mktemp("dbs") / "data.db") + db = sqlite3.connect(db_path) + db.execute("create table posts (id integer primary key, website text)") + db.execute("insert into posts values (1, ?)", [LONG_URL]) + db.execute("insert into posts values (2, 'not a url')") + db.commit() + db.close() + ds = Datasette( + [db_path], + settings={"truncate_cells_html": 30}, + config={ + "databases": { + "data": {"tables": {"posts": {"column_types": {"website": "url"}}}} + } + }, + ) + yield ds + ds.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "truncate_cells_html,expected_text", + ( + (30, "https://images.openfoodfac….jpg"), + # 0 disables truncation + (0, LONG_URL), + ), +) +async def test_url_column_type_truncates_link_text( + ds_long_url, truncate_cells_html, expected_text +): + # The table page truncates the link text but the href keeps the full URL + ds_long_url._settings["truncate_cells_html"] = truncate_cells_html + await ds_long_url.invoke_startup() + response = await ds_long_url.client.get("/data/posts") + assert response.status_code == 200 + link = Soup(response.text, "html.parser").select_one("td.col-website a") + assert link["href"] == LONG_URL + assert link.text == expected_text + + +@pytest.mark.asyncio +async def test_url_column_type_full_link_text_on_row_page(ds_long_url): + # Row pages show full values, so the link text is not truncated there + await ds_long_url.invoke_startup() + response = await ds_long_url.client.get("/data/posts/1") + assert response.status_code == 200 + link = Soup(response.text, "html.parser").select_one("td.col-website a") + assert link["href"] == LONG_URL + assert link.text == LONG_URL + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ("/data/posts.json", "/data/posts/1.json")) +async def test_url_column_type_full_link_text_in_json(ds_long_url, path): + # truncate_cells_html only applies to the HTML table view + await ds_long_url.invoke_startup() + response = await ds_long_url.client.get(path + "?_extra=render_cell") + assert response.status_code == 200 + rendered = response.json()["render_cell"][0]["website"] + assert rendered == f'{LONG_URL}' + + +@pytest.mark.asyncio +async def test_url_column_type_non_http_value_unchanged(ds_long_url): + await ds_long_url.invoke_startup() + response = await ds_long_url.client.get("/data/posts") + assert response.status_code == 200 + td = Soup(response.text, "html.parser").select("td.col-website")[1] + assert td.find("a") is None + assert td.text == "not a url" + + @pytest.mark.asyncio async def test_set_column_type_ui_data_hidden_without_permission(ds_ct): await ds_ct.invoke_startup()