UI for setting custom column types, refs #2671

This commit is contained in:
Simon Willison 2026-03-18 14:03:42 -07:00
commit cb293572c4
5 changed files with 617 additions and 0 deletions

View file

@ -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) {

View file

@ -10,6 +10,9 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</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 = `
<div class="modal-header">
<span class="modal-title" id="set-column-type-title">Set custom type</span>
<span class="modal-meta"></span>
</div>
<p class="set-column-type-status"></p>
<p class="set-column-type-error" hidden></p>
<div class="set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
</div>
`;
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 = "";

View file

@ -154,6 +154,11 @@
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
</script>
{% endif %}
{% if set_column_type_ui %}
<script>
window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script>
{% endif %}
{% include custom_table_templates %}

View file

@ -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,

View file

@ -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 ---