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()