First draft of makeColumnField() plugin hook

This commit is contained in:
Simon Willison 2026-06-14 11:57:13 -07:00
commit b2de8b5d2e
6 changed files with 510 additions and 21 deletions

View file

@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
<style>
:host {
--ink: #0f0f0f;
--paper: #f5f3ef;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #e2dfd8;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;

View file

@ -82,6 +82,35 @@ const datasetteManager = {
return columnActions;
},
/**
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
* for specific Datasette column types.
*
* The first plugin to return a control object wins. Returning null or
* undefined means "I do not handle this field".
*/
makeColumnField: (context) => {
for (const [pluginName, plugin] of datasetteManager.plugins) {
if (!plugin.makeColumnField) {
continue;
}
let control = null;
try {
control = plugin.makeColumnField(context);
} catch (error) {
console.error(
`Error in makeColumnField() for plugin ${pluginName}`,
error,
);
continue;
}
if (control) {
return Object.assign({ pluginName }, control);
}
}
return null;
},
makeJumpSections: (context) => {
let jumpSections = [];

View file

@ -492,12 +492,16 @@ function tableBaseUrl() {
return url;
}
function tablePageData() {
return window._datasetteTableData || {};
}
function tableInsertData() {
return window._datasetteTableData && window._datasetteTableData.insertRow;
return tablePageData().insertRow;
}
function tableForeignKeys() {
return (window._datasetteTableData && window._datasetteTableData.foreignKeys) || {};
return tablePageData().foreignKeys || {};
}
function foreignKeyAutocompleteUrl(column) {
@ -982,6 +986,118 @@ function rowEditControlElement(control, autocompleteUrl) {
return autocomplete;
}
function columnFormControlContext(column, value, isPk, columnType, options) {
var pageData = tablePageData();
var hasDefault =
options.hasDefault ||
(options.defaultValue !== null && typeof options.defaultValue !== "undefined");
return {
mode: options.mode || "edit",
database: pageData.database || null,
table: pageData.table || (tableInsertData() && tableInsertData().tableName) || null,
tableUrl: pageData.tableUrl || null,
column: column,
value: value,
originalValue: value,
columnType: columnType || null,
sqliteType: options.sqliteType || null,
notNull: !!options.notnull,
isPrimaryKey: !!isPk,
readOnly: !!(isPk && options.primaryKeyReadonly !== false),
hasDefault: !!hasDefault,
defaultValue: options.defaultValue,
form: options.form || null,
dialog: options.dialog || null,
};
}
function makeColumnField(manager, context) {
if (!manager || !manager.makeColumnField) {
return null;
}
return manager.makeColumnField(context);
}
function renderColumnField(pluginControl, fieldApi) {
if (!pluginControl || !pluginControl.render) {
return null;
}
var pluginWrap = document.createElement("div");
pluginWrap.className = "row-edit-plugin-control";
pluginWrap.dataset.pluginName = pluginControl.pluginName || "";
pluginWrap.dataset.column = fieldApi.context.column;
if (fieldApi.context.columnType && fieldApi.context.columnType.type) {
pluginWrap.dataset.columnType = fieldApi.context.columnType.type;
}
try {
var rendered = pluginControl.render(pluginWrap, fieldApi);
if (rendered && rendered.nodeType) {
pluginWrap.appendChild(rendered);
}
} catch (error) {
console.error("Error rendering column form control", error);
return null;
}
pluginWrap._datasetteColumnField = pluginControl;
pluginWrap._datasetteColumnFormField = fieldApi;
return pluginWrap;
}
function focusRowEditPluginControl(field) {
var pluginWrap = field.querySelector(".row-edit-plugin-control");
if (!pluginWrap) {
return false;
}
var pluginControl = pluginWrap._datasetteColumnField;
var fieldApi = pluginWrap._datasetteColumnFormField;
if (pluginControl && pluginControl.focus) {
pluginControl.focus(pluginWrap, fieldApi);
return true;
}
return false;
}
function focusFirstRowEditControl(state, options) {
options = options || {};
var fields = state.fields.querySelectorAll(".row-edit-field");
for (var i = 0; i < fields.length; i += 1) {
var field = fields[i];
var control = field.querySelector(".row-edit-input");
if (!control) {
continue;
}
if (options.skipReadonly && (control.readOnly || control.disabled)) {
continue;
}
if (focusRowEditPluginControl(field)) {
return true;
}
control.focus();
return true;
}
return false;
}
function destroyRowEditFields(state) {
if (!state || !state.fields) {
return;
}
state.fields
.querySelectorAll(".row-edit-plugin-control")
.forEach(function (pluginWrap) {
var pluginControl = pluginWrap._datasetteColumnField;
var fieldApi = pluginWrap._datasetteColumnFormField;
if (pluginControl && pluginControl.destroy) {
try {
pluginControl.destroy(pluginWrap, fieldApi);
} catch (error) {
console.error("Error destroying column form control", error);
}
}
});
state.fields.innerHTML = "";
}
function createRowEditField(column, value, isPk, columnType, index, options) {
options = options || {};
var field = document.createElement("div");
@ -993,15 +1109,26 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
var fieldId = "row-edit-field-" + index;
var metaId = "row-edit-field-meta-" + index;
var labelId = "row-edit-field-label-" + index;
var label = document.createElement("label");
label.className = "row-edit-label";
label.id = labelId;
label.setAttribute("for", fieldId);
label.textContent = column;
var controlWrap = document.createElement("div");
controlWrap.className = "row-edit-control-wrap";
var control = shouldUseTextarea(value, columnType)
var context = columnFormControlContext(
column,
value,
isPk,
columnType,
options,
);
var pluginControl = makeColumnField(options.manager, context);
var preferredControl = pluginControl && pluginControl.inputType;
var control = (preferredControl === "textarea" || shouldUseTextarea(value, columnType))
? document.createElement("textarea")
: document.createElement("input");
control.className = "row-edit-input";
@ -1081,8 +1208,20 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
meta.appendChild(foreignKeyLinkWrap);
updateRowEditFieldMetaHidden(meta);
}
var controlElement = rowEditControlElement(control, options.autocompleteUrl);
if (options.autocompleteUrl) {
var fieldApi = {
id: fieldId,
labelId: labelId,
descriptionId: metaId,
input: control,
control: control,
form: options.form || null,
dialog: options.dialog || null,
context: context,
};
var pluginControlElement = renderColumnField(pluginControl, fieldApi);
var controlElement =
pluginControlElement || rowEditControlElement(control, options.autocompleteUrl);
if (options.autocompleteUrl && !pluginControlElement) {
control.addEventListener("input", function () {
setForeignKeyMetaLink(meta, options.autocompleteUrl, null);
});
@ -1506,7 +1645,7 @@ function renderRowEditFields(state, data) {
var primaryKeys = data.primary_keys || [];
var columnTypes = data.column_types || {};
state.fields.innerHTML = "";
destroyRowEditFields(state);
columns.forEach(function (column, index) {
state.fields.appendChild(
createRowEditField(
@ -1517,6 +1656,10 @@ function renderRowEditFields(state, data) {
index,
{
autocompleteUrl: foreignKeyAutocompleteUrl(column),
dialog: state.dialog,
form: state.form,
manager: state.manager,
mode: state.mode,
primaryKeyReadonly: true,
},
),
@ -1525,15 +1668,15 @@ function renderRowEditFields(state, data) {
state.hasLoaded = true;
updateRowEditDialogButtons(state);
var firstEditable = state.fields.querySelector(".row-edit-input:not([readonly])");
var firstField = state.fields.querySelector(".row-edit-input");
(firstEditable || firstField || state.cancelButton).focus();
if (!focusFirstRowEditControl(state, { skipReadonly: true })) {
focusFirstRowEditControl(state) || state.cancelButton.focus();
}
}
function renderRowInsertFields(state, data) {
var columns = data.columns || [];
state.fields.innerHTML = "";
destroyRowEditFields(state);
columns.forEach(function (column, index) {
state.fields.appendChild(
createRowEditField(
@ -1544,10 +1687,15 @@ function renderRowInsertFields(state, data) {
index,
{
autocompleteUrl: foreignKeyAutocompleteUrl(column.name),
dialog: state.dialog,
defaultValue: column.default,
form: state.form,
hasDefault: column.has_default,
manager: state.manager,
mode: state.mode,
notnull: column.notnull,
primaryKeyReadonly: false,
sqliteType: column.type,
useDefaultInitially: column.has_default,
valueType: column.value_type,
},
@ -1564,10 +1712,13 @@ function renderRowInsertFields(state, data) {
state.hasLoaded = true;
updateRowEditDialogButtons(state);
var firstControl = state.fields.querySelector(
".row-edit-default-set-value, .row-edit-input:not(:disabled)",
);
(firstControl || state.saveButton).focus();
var firstDefaultButton = state.fields.querySelector(".row-edit-default-set-value");
if (firstDefaultButton) {
firstDefaultButton.focus();
} else {
focusFirstRowEditControl(state, { skipReadonly: true }) ||
state.saveButton.focus();
}
}
function setRowDialogTitle(title, text, codeText, labelText) {
@ -1692,6 +1843,7 @@ function ensureRowEditDialog(manager) {
state.loadId += 1;
clearRowEditDialogError(state);
state.hasLoaded = false;
destroyRowEditFields(state);
setRowEditDialogLoading(state, false);
setRowEditDialogSaving(state, false);
if (
@ -1737,7 +1889,7 @@ async function openRowEditDialog(button, manager) {
clearRowEditDialogError(state);
setRowEditDialogLoading(state, true);
state.fields.innerHTML = "";
destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle(
state.title,
@ -1809,7 +1961,7 @@ function openRowInsertDialog(button, manager) {
clearRowEditDialogError(state);
setRowEditDialogLoading(state, false);
state.fields.innerHTML = "";
destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle(
state.title,

View file

@ -279,7 +279,11 @@ async def _foreign_key_autocomplete_urls(
async def _table_page_data(
datasette, request, db, database_name, table_name, is_view, table_insert_ui
):
data = {"tableUrl": datasette.urls.table(database_name, table_name)}
data = {
"database": database_name,
"table": table_name,
"tableUrl": datasette.urls.table(database_name, table_name),
}
if table_insert_ui:
data["insertRow"] = table_insert_ui
if not is_view:

View file

@ -46,6 +46,9 @@ The ``datasetteManager`` object
``registerPlugin(name, implementation)``
Call this to register a plugin, passing its name and implementation
``makeColumnField(context)``
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
``selectors`` - object
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
@ -188,6 +191,301 @@ This example plugin adds two menu items - one to copy the column name to the cli
});
});
.. _javascript_plugins_makeColumnField:
makeColumnField(context)
~~~~~~~~~~~~~~~~~~~~~~~~
This method, if present, can provide a custom form field for a column in Datasette's row insert and edit dialogs.
It is designed for plugins that register custom column types using the Python ``register_column_types()`` plugin hook. For example, a plugin that defines a ``file`` column type can use ``makeColumnField()`` to replace a plain text input with a file picker, and a plugin that defines a rich text column type can use it to enhance the field with an editor.
Datasette calls ``makeColumnField(context)`` on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the ``context`` object and return ``null`` or ``undefined`` for fields they do not handle.
The first plugin to return a truthy control object is used for that field. Plugins are called in registration order. If a plugin raises an exception, Datasette logs the error to the browser console and continues to the next plugin.
The value that Datasette submits is still read from the core-owned input or textarea provided to the plugin as ``field.input``. This keeps custom fields progressive: the plugin can render any UI it needs, but it must keep ``field.input.value`` synchronized with the raw value that should be sent to the insert/update API.
Context object
^^^^^^^^^^^^^^
``makeColumnField(context)`` is called with a context object describing the field. The current context object has these keys:
``mode`` - string
``"insert"`` or ``"edit"``.
``database`` - string or null
The database name.
``table`` - string or null
The table name.
``tableUrl`` - string or null
The path to the table page, including any configured base URL prefix.
``column`` - string
The column name.
``value``
The current JavaScript value for the field. For edit forms this is the row's current value. For insert forms this is usually ``null`` or ``""``.
``originalValue``
The value the field had when the form was opened. This currently matches ``value``.
``columnType`` - object or null
The configured Datasette column type for this column, if one exists. This object includes a ``type`` key containing the column type name. Plugins should generally check ``context.columnType && context.columnType.type`` before deciding whether to handle a field.
``sqliteType`` - string or null
The SQLite column type, if known.
``notNull`` - boolean
True if the column is defined as ``NOT NULL``.
``isPrimaryKey`` - boolean
True if this column is part of the table's primary key.
``readOnly`` - boolean
True if Datasette is rendering the field as read-only. Primary key fields are read-only in edit forms by default.
``hasDefault`` - boolean
True if the column has a SQLite default value and the insert form can offer the "use default" behavior.
``defaultValue``
The column default value or expression, if available.
``form`` - ``HTMLFormElement`` or null
The row insert/edit form element.
``dialog`` - ``HTMLDialogElement`` or null
The modal dialog element.
Returned control object
^^^^^^^^^^^^^^^^^^^^^^^
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
``inputType`` - string, optional
If set to ``"textarea"``, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. Any other value is ignored.
``render(node, field)`` - function
Called once to render the custom field UI. ``node`` is an empty container element created by Datasette. ``field`` is a helper object described below.
The plugin should append its UI to ``node``. If ``render()`` returns a DOM node, Datasette appends that returned node to ``node``.
``focus(node, field)`` - function, optional
Called when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
``destroy(node, field)`` - function, optional
Called when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
Datasette adds a ``pluginName`` property to the control object internally, based on the name passed to ``registerPlugin()``.
The field helper object
^^^^^^^^^^^^^^^^^^^^^^^
The second argument to ``render(node, field)`` provides the core input and stable IDs that help the plugin integrate with the modal's form and accessibility behavior:
``id`` - string
The ID Datasette assigned to the underlying form control.
``labelId`` - string
The ID of the visible field label.
``descriptionId`` - string
The ID of the field metadata/help text. This metadata can include details such as ``Primary key``, ``Required``, ``Current value: NULL`` or ``Custom type: file``.
``input`` - ``HTMLInputElement`` or ``HTMLTextAreaElement``
The core-owned form control. Datasette reads this element's ``name``, ``value`` and ``dataset`` properties when the row is inserted or updated.
``control``
An alias for ``input``.
``form`` - ``HTMLFormElement`` or null
The containing row insert/edit form.
``dialog`` - ``HTMLDialogElement`` or null
The containing modal dialog.
``context`` - object
The original context object passed to ``makeColumnField()``.
Value handling
^^^^^^^^^^^^^^
Custom fields should keep ``field.input.value`` synchronized with the raw value to submit.
If a custom field changes the value programmatically, it should dispatch normal ``input`` and ``change`` events so the rest of the form can observe the update:
.. code-block:: javascript
function setInputValue(input, value) {
input.value = value || "";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
Plugins can either keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
If the plugin changes the kind of value stored in the underlying input, it can adjust ``field.input.dataset.originalValueType``. Datasette uses that dataset value when converting the submitted text back to a JSON value for the insert/update API.
For example, a file picker that stores a string file ID can set:
.. code-block:: javascript
field.input.type = "hidden";
field.input.dataset.originalValueType = "null";
This causes an empty string to be submitted as ``null``.
Lazy loading large controls
^^^^^^^^^^^^^^^^^^^^^^^^^^^
The JavaScript file that registers ``makeColumnField()`` should be small. If the actual control is large, load it from inside ``render()`` using dynamic ``import()``. That way the heavier code is only downloaded after a user opens an insert/edit dialog containing a matching column type.
.. code-block:: javascript
const editorUrl = new URL("./editor.js", import.meta.url).href;
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("my-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "my-editor") {
return null;
}
return {
inputType: "textarea",
render(node, field) {
node.appendChild(field.input);
import(editorUrl).then(function () {
// Enhance field.input here.
});
}
};
}
});
});
Example: textarea-backed custom element
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom element:
.. code-block:: javascript
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("markdown-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "markdown-editor") {
return null;
}
return {
inputType: "textarea",
render(node, field) {
const editor = document.createElement("my-markdown-editor");
editor.appendChild(field.input);
node.appendChild(editor);
if (field.labelId) {
field.input.setAttribute("aria-labelledby", field.labelId);
}
if (field.descriptionId) {
field.input.setAttribute("aria-describedby", field.descriptionId);
}
},
focus(node, field) {
const editor = node.querySelector("my-markdown-editor");
if (editor && editor.focus) {
editor.focus();
} else {
field.input.focus();
}
}
};
}
});
});
Example: hidden input with custom picker
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This example handles an ``asset`` column type by hiding the core input and writing an asset ID into it when the user selects an item:
.. code-block:: javascript
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("asset-picker", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "asset") {
return null;
}
return {
render(node, field) {
field.input.type = "hidden";
field.input.dataset.originalValueType = "null";
const group = document.createElement("div");
group.setAttribute("role", "group");
group.setAttribute("aria-labelledby", field.labelId);
group.setAttribute("aria-describedby", field.descriptionId);
const current = document.createElement("span");
current.textContent = field.input.value || "No asset selected";
const button = document.createElement("button");
button.type = "button";
button.textContent = "Choose asset";
button.addEventListener("click", async function () {
const assetId = await chooseAsset();
if (assetId === null) {
return;
}
field.input.value = assetId;
field.input.dispatchEvent(new Event("input", { bubbles: true }));
field.input.dispatchEvent(new Event("change", { bubbles: true }));
current.textContent = assetId || "No asset selected";
});
group.appendChild(current);
group.appendChild(button);
node.appendChild(field.input);
node.appendChild(group);
},
focus(node) {
const button = node.querySelector("button");
if (button) {
button.focus();
}
}
};
}
});
});
Accessibility
^^^^^^^^^^^^^
Custom fields are responsible for preserving the accessibility of the form:
- The visible field label should name the control. Use ``field.labelId`` with ``aria-labelledby`` when wrapping or replacing the visible input.
- Field metadata should remain available to assistive technology. Use ``field.descriptionId`` with ``aria-describedby``.
- Keyboard users must be able to operate every part of the custom field.
- If the field opens an inline picker or other nested UI, ``Escape`` should close that nested UI first and return focus to a sensible element.
- If a control performs asynchronous loading, expose loading and error states in the UI. Use appropriate ARIA live regions where the state change is important to understand the field.
- If a plugin hides ``field.input``, the replacement UI must still make the current value and available actions clear.
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
.. _javascript_datasette_manager_selectors:
Selectors

View file

@ -881,7 +881,11 @@ async def test_row_delete_action_data_attributes():
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert table_data_from_soup(soup) == {"tableUrl": "/data/items"}
assert table_data_from_soup(soup) == {
"database": "data",
"table": "items",
"tableUrl": "/data/items",
}
assert soup.select_one('button[data-table-action="insert-row"]') is None
row = soup.select_one("table.rows-and-columns tbody tr")
@ -1151,7 +1155,9 @@ def test_table_data_uses_base_url(app_client_base_url_prefix):
re.DOTALL,
)
assert json.loads(match.group(1)) == {
"tableUrl": "/prefix/fixtures/simple_primary_key"
"database": "fixtures",
"table": "simple_primary_key",
"tableUrl": "/prefix/fixtures/simple_primary_key",
}