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