From cb293572c4b70ef064f32673a282b113ab3fd651 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 18 Mar 2026 14:03:42 -0700 Subject: [PATCH] UI for setting custom column types, refs #2671 --- datasette/static/app.css | 189 ++++++++++++++++++++++ datasette/static/table.js | 278 +++++++++++++++++++++++++++++++++ datasette/templates/table.html | 5 + datasette/views/table.py | 43 +++++ tests/test_column_types.py | 102 ++++++++++++ 5 files changed, 617 insertions(+) diff --git a/datasette/static/app.css b/datasette/static/app.css index 0a6efd4c..26717c43 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -986,6 +986,180 @@ dialog.mobile-column-actions-dialog::backdrop { color: var(--ink); } +dialog.set-column-type-dialog { + --ink: #0f0f0f; + --paper: #f5f3ef; + --muted: #6b6b6b; + --rule: #e2dfd8; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(520px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(720px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.set-column-type-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.set-column-type-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.set-column-type-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; +} + +.set-column-type-dialog .modal-title { + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.set-column-type-dialog .modal-meta { + font-family: ui-monospace, monospace; + font-size: 0.7rem; + color: var(--muted); + background: var(--paper); + padding: 3px 9px; + border-radius: 20px; +} + +.set-column-type-status, +.set-column-type-empty, +.set-column-type-error { + margin: 0; + padding: 12px 24px 0; +} + +.set-column-type-status, +.set-column-type-empty { + color: var(--muted); + font-size: 0.9rem; +} + +.set-column-type-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.set-column-type-options { + padding: 16px 24px 24px; + overflow-y: auto; + display: grid; + gap: 12px; +} + +.set-column-type-option { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: start; + padding: 14px 16px; + border: 1px solid var(--rule); + border-radius: 8px; + background: #fcfbf9; + cursor: pointer; +} + +.set-column-type-option:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12); +} + +.set-column-type-option input { + margin-top: 3px; +} + +.set-column-type-option-content { + display: grid; + gap: 4px; +} + +.set-column-type-option-name { + font-family: ui-monospace, monospace; + font-size: 0.95rem; + color: var(--ink); +} + +.set-column-type-option-description { + color: var(--muted); + font-size: 0.9rem; +} + +.set-column-type-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.set-column-type-dialog .footer-info { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 0.68rem; + color: var(--muted); +} + +.set-column-type-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.set-column-type-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.set-column-type-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.set-column-type-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.set-column-type-dialog .btn-primary:hover { + background: #1949b8; +} + +.set-column-type-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + @media (max-width: 640px) { dialog.mobile-column-actions-dialog { width: 95vw; @@ -1018,6 +1192,21 @@ dialog.mobile-column-actions-dialog::backdrop { padding-left: 18px; padding-right: 18px; } + + dialog.set-column-type-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .set-column-type-dialog .modal-header, + .set-column-type-status, + .set-column-type-empty, + .set-column-type-error, + .set-column-type-options { + padding-left: 18px; + padding-right: 18px; + } } @media only screen and (max-width: 576px) { diff --git a/datasette/static/table.js b/datasette/static/table.js index 1e243703..e9115453 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -10,6 +10,9 @@ var DROPDOWN_ICON_SVG = ` `; +var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog"; +var setColumnTypeDialogState = null; + function getParams() { return new URLSearchParams(location.search); } @@ -99,6 +102,259 @@ function getColumnTypeText(th) { return `Type: ${columnType.toUpperCase()}${notNull}`; } +function getSetColumnTypeData() { + return window._setColumnTypeData || null; +} + +function getSetColumnTypeConfig(column) { + var data = getSetColumnTypeData(); + if (!data || !data.columns) { + return null; + } + return data.columns[column] || null; +} + +function canSetColumnType() { + return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch); +} + +function setColumnTypeActionLabel(column) { + var columnConfig = getSetColumnTypeConfig(column); + if (!columnConfig) { + return null; + } + return columnConfig.current + ? `Custom type: ${columnConfig.current.type}` + : "Set custom type"; +} + +function createSetColumnTypeOption(value, name, description, checked) { + var label = document.createElement("label"); + label.className = "set-column-type-option"; + + var input = document.createElement("input"); + input.type = "radio"; + input.name = "set-column-type-choice"; + input.value = value; + input.checked = checked; + + var content = document.createElement("span"); + content.className = "set-column-type-option-content"; + + var title = document.createElement("span"); + title.className = "set-column-type-option-name"; + title.textContent = name; + + var detail = document.createElement("span"); + detail.className = "set-column-type-option-description"; + detail.textContent = description; + + content.appendChild(title); + content.appendChild(detail); + label.appendChild(input); + label.appendChild(content); + return label; +} + +function setSetColumnTypeDialogBusy(state, isBusy) { + state.isBusy = isBusy; + state.saveButton.disabled = isBusy; + state.cancelButton.disabled = isBusy; + Array.from( + state.optionsWrap.querySelectorAll('input[name="set-column-type-choice"]'), + ).forEach(function (input) { + input.disabled = isBusy; + }); + state.saveButton.textContent = isBusy ? "Saving..." : "Save"; +} + +function clearSetColumnTypeDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; +} + +function showSetColumnTypeDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; +} + +function ensureSetColumnTypeDialog() { + if (setColumnTypeDialogState) { + return setColumnTypeDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = SET_COLUMN_TYPE_DIALOG_ID; + dialog.className = "set-column-type-dialog"; + dialog.setAttribute("aria-labelledby", "set-column-type-title"); + dialog.innerHTML = ` + +

+ +
+ + `; + document.body.appendChild(dialog); + + setColumnTypeDialogState = { + dialog: dialog, + meta: dialog.querySelector(".modal-meta"), + status: dialog.querySelector(".set-column-type-status"), + error: dialog.querySelector(".set-column-type-error"), + optionsWrap: dialog.querySelector(".set-column-type-options"), + footerInfo: dialog.querySelector(".footer-info"), + cancelButton: dialog.querySelector(".set-column-type-cancel"), + saveButton: dialog.querySelector(".set-column-type-save"), + currentColumn: null, + currentConfig: null, + isBusy: false, + }; + + setColumnTypeDialogState.cancelButton.addEventListener("click", function () { + if (!setColumnTypeDialogState.isBusy) { + dialog.close(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog && !setColumnTypeDialogState.isBusy) { + dialog.close(); + } + }); + + dialog.addEventListener("cancel", function (ev) { + if (setColumnTypeDialogState.isBusy) { + ev.preventDefault(); + } + }); + + dialog.addEventListener("close", function () { + clearSetColumnTypeDialogError(setColumnTypeDialogState); + setSetColumnTypeDialogBusy(setColumnTypeDialogState, false); + }); + + setColumnTypeDialogState.saveButton.addEventListener("click", async function () { + var state = setColumnTypeDialogState; + var selected = state.dialog.querySelector( + 'input[name="set-column-type-choice"]:checked', + ); + var selectedType = selected ? selected.value : ""; + var currentType = state.currentConfig.current + ? state.currentConfig.current.type + : ""; + + if (selectedType === currentType) { + state.dialog.close(); + return; + } + + clearSetColumnTypeDialogError(state); + setSetColumnTypeDialogBusy(state, true); + + var payload = { + column: state.currentColumn, + column_type: selectedType ? { type: selectedType } : null, + }; + + try { + var response = await fetch(getSetColumnTypeData().path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + var data = await response.json(); + if (!response.ok || data.ok === false) { + var message = (data.errors || ["Request failed"]).join(" "); + throw new Error(message); + } + location.reload(); + } catch (error) { + setSetColumnTypeDialogBusy(state, false); + showSetColumnTypeDialogError(state, error.message || "Request failed"); + } + }); + + return setColumnTypeDialogState; +} + +function openSetColumnTypeDialog(th) { + var column = th.dataset.column; + var columnConfig = getSetColumnTypeConfig(column); + if (!columnConfig) { + return; + } + + var state = ensureSetColumnTypeDialog(); + if (!state) { + return; + } + + clearSetColumnTypeDialogError(state); + setSetColumnTypeDialogBusy(state, false); + state.currentColumn = column; + state.currentConfig = columnConfig; + state.status.textContent = `Column: ${column}`; + state.meta.textContent = getColumnTypeText(th) || "Type unavailable"; + state.footerInfo.textContent = columnConfig.current + ? `Current custom type: ${columnConfig.current.type}` + : "No custom type set."; + state.optionsWrap.innerHTML = ""; + + var currentType = columnConfig.current ? columnConfig.current.type : ""; + state.optionsWrap.appendChild( + createSetColumnTypeOption( + "", + "No custom type", + "Use standard Datasette rendering without a custom type.", + currentType === "", + ), + ); + + columnConfig.options.forEach(function (option) { + state.optionsWrap.appendChild( + createSetColumnTypeOption( + option.name, + option.name, + option.description, + option.name === currentType, + ), + ); + }); + + if (!columnConfig.options.length) { + var emptyState = document.createElement("p"); + emptyState.className = "set-column-type-empty"; + emptyState.textContent = + "No registered custom types are compatible with this SQLite type."; + state.optionsWrap.appendChild(emptyState); + } + + if (!state.dialog.open) { + state.dialog.showModal(); + } + var selectedOption = state.dialog.querySelector( + 'input[name="set-column-type-choice"]:checked', + ); + if (selectedOption) { + selectedOption.focus(); + } else { + state.saveButton.focus(); + } +} + function canChooseColumns() { return !!( document.querySelector("column-chooser") && window._columnChooserData @@ -171,6 +427,21 @@ function buildColumnActionItems(manager, th, options) { }); } + if (canSetColumnType() && getSetColumnTypeConfig(column)) { + columnActions.push({ + label: setColumnTypeActionLabel(column), + href: "#", + onClick: + options.onSetColumnType || + function (ev) { + ev.preventDefault(); + window.setTimeout(function () { + openSetColumnTypeDialog(th); + }, 0); + }, + }); + } + if (th.dataset.isPk !== "1" && hasMultipleVisibleColumns(manager)) { columnActions.push({ label: "Hide this column", @@ -281,6 +552,13 @@ const initDatasetteTable = function (manager) { closeMenu(); openColumnChooser(); }, + onSetColumnType: function (ev) { + ev.preventDefault(); + closeMenu(); + window.setTimeout(function () { + openSetColumnTypeDialog(th); + }, 0); + }, }); var menuList = menu.querySelector("ul.dropdown-actions"); menuList.innerHTML = ""; diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 0df08a94..2919d306 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -154,6 +154,11 @@ window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }}; {% endif %} +{% if set_column_type_ui %} + +{% endif %} {% include custom_table_templates %} diff --git a/datasette/views/table.py b/datasette/views/table.py index e7a226af..5643858d 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1721,6 +1721,47 @@ async def table_view_data( for col_name, ct in ct_map.items() } + async def extra_set_column_type_ui(): + "Column type UI metadata for this table" + if is_view: + return None + + if not await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_details = await datasette._get_resource_column_details( + database_name, table_name + ) + ct_map = await datasette.get_column_types(database_name, table_name) + columns = {} + for column_name, column_detail in column_details.items(): + current = ct_map.get(column_name) + columns[column_name] = { + "current": ( + {"type": current.name, "config": current.config} + if current is not None + else None + ), + "options": [ + { + "name": name, + "description": ct_cls.description, + } + for name, ct_cls in sorted(datasette._column_types.items()) + if datasette._column_type_is_applicable(ct_cls, column_detail) + ], + } + return { + "path": "{}/-/set-column-type".format( + datasette.urls.table(database_name, table_name) + ), + "columns": columns, + } + async def extra_metadata(): "Metadata about the table and database" tablemetadata = await datasette.get_resource_metadata(database_name, table_name) @@ -1903,6 +1944,7 @@ async def table_view_data( "all_columns", "expandable_columns", "form_hidden_args", + "set_column_type_ui", ] } @@ -1931,6 +1973,7 @@ async def table_view_data( extra_request, extra_query, extra_column_types, + extra_set_column_type_ui, extra_metadata, extra_extras, extra_database, diff --git a/tests/test_column_types.py b/tests/test_column_types.py index 4fd30812..68b92a39 100644 --- a/tests/test_column_types.py +++ b/tests/test_column_types.py @@ -1,5 +1,7 @@ +import json import logging +from bs4 import BeautifulSoup as Soup from datasette.app import Datasette from datasette.column_types import ( ColumnType, @@ -56,6 +58,49 @@ def ds_ct(tmp_path_factory): database.close() +@pytest.fixture +def ds_ct_editor_permission(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table posts (id integer primary key, title text, body text, " + "author_email text, website text, metadata text)" + ) + db.execute( + "insert into posts values (1, 'Hello', '# World', 'test@example.com', " + "'https://example.com', '{\"key\": \"value\"}')" + ) + db.commit() + ds = Datasette( + [db_path], + config={ + "databases": { + "data": { + "tables": { + "posts": { + "permissions": {"set-column-type": {"id": "editor"}}, + "column_types": { + "body": "markdown", + "author_email": "email", + "website": "url", + "metadata": "json", + }, + } + } + } + } + }, + ) + ds.root_enabled = True + yield ds + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + def write_token(ds, actor_id="root", permissions=None): to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())} if permissions: @@ -70,6 +115,19 @@ def _headers(token): } +def _window_data_from_html(html, variable_name): + soup = Soup(html, "html.parser") + scripts = soup.find_all("script") + matching_scripts = [ + script for script in scripts if variable_name in (script.string or "") + ] + assert len(matching_scripts) == 1 + script_text = matching_scripts[0].string.strip() + prefix = f"window.{variable_name} = " + assert script_text.startswith(prefix) + return json.loads(script_text[len(prefix) :].rstrip(";")) + + # --- Internal DB and config loading --- @@ -860,6 +918,50 @@ async def test_html_table_page_rendering(ds_ct): assert 'href="https://example.com"' in html +@pytest.mark.asyncio +async def test_set_column_type_ui_data_hidden_without_permission(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts") + assert response.status_code == 200 + assert "window._setColumnTypeData" not in response.text + + +@pytest.mark.asyncio +async def test_set_column_type_ui_data_includes_applicable_types( + ds_ct_editor_permission, +): + await ds_ct_editor_permission.invoke_startup() + response = await ds_ct_editor_permission.client.get( + "/data/posts", + cookies={ + "ds_actor": ds_ct_editor_permission.client.actor_cookie({"id": "editor"}) + }, + ) + assert response.status_code == 200 + data = _window_data_from_html(response.text, "_setColumnTypeData") + assert data["path"] == "/data/posts/-/set-column-type" + assert data["columns"]["id"] == { + "current": None, + "options": [], + } + assert data["columns"]["title"] == { + "current": None, + "options": [ + {"name": "email", "description": "Email address"}, + {"name": "json", "description": "JSON data"}, + {"name": "url", "description": "URL"}, + ], + } + assert data["columns"]["author_email"] == { + "current": {"type": "email", "config": None}, + "options": [ + {"name": "email", "description": "Email address"}, + {"name": "json", "description": "JSON data"}, + {"name": "url", "description": "URL"}, + ], + } + + # --- Validation on upsert ---