From 841a2536ea993ef31b8161b1470367da397a5eae Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 13:54:10 -0700 Subject: [PATCH] Tighten row edit field plugin API Replace the value/valueType/originalValue/originalValueType fields on makeColumnField() contexts with an explicit field object API for reading, writing, resetting, comparing and validating field values. Normalize columnType to {type, config}, rename the SQLite default metadata so it is clearly SQLite-specific, and document that plugins submit only string, number, boolean or null values. Plugins that need structured data should serialize it themselves instead of relying on Datasette to special-case JSON. Move the built-in json column type behavior onto the same plugin API used by external plugins: validate the textarea with field.setValidity() as the value changes, but submit plain text. Harden row edit value comparison so fixing invalid JSON in an existing row is not blocked by the original invalid value. Update the JavaScript plugin documentation and Node-based tests for the revised field contract. --- datasette/static/app.css | 20 ++ datasette/static/table.js | 367 +++++++++++++++----- datasette/views/table.py | 13 +- docs/javascript_plugins.rst | 173 ++++++---- tests/test_datasette_manager_js.py | 515 +++++++++++++++++++++++++++++ tests/test_table_html.py | 10 +- 6 files changed, 958 insertions(+), 140 deletions(-) create mode 100644 tests/test_datasette_manager_js.py diff --git a/datasette/static/app.css b/datasette/static/app.css index 633ebd7a..c053f484 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1521,6 +1521,16 @@ textarea.row-edit-input { outline: 3px solid rgba(26, 86, 219, 0.12); } +.row-edit-input[aria-invalid="true"] { + border-color: #b42318; + background: #fff8f7; +} + +.row-edit-input[aria-invalid="true"]:focus { + border-color: #b42318; + outline-color: rgba(180, 35, 24, 0.16); +} + .row-edit-input[readonly] { color: var(--muted); background: var(--paper); @@ -1594,6 +1604,16 @@ textarea.row-edit-input { font-size: 0.78rem; } +.row-edit-field-validation-error { + color: #b42318; + display: block; + margin-top: 2px; +} + +.row-edit-field-validation-error[hidden] { + display: none; +} + .row-edit-field-meta-autocomplete { line-height: 1.2; min-height: 1.2em; diff --git a/datasette/static/table.js b/datasette/static/table.js index 27b816be..79ed45b9 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -959,7 +959,7 @@ function shouldUseTextarea(value, columnType) { return text.length > 80 || /[\r\n]/.test(text); } -function rowEditValueType(value) { +function rowEditValueKind(value) { if (value === null || typeof value === "undefined") { return "null"; } @@ -969,9 +969,6 @@ function rowEditValueType(value) { if (typeof value === "boolean") { return "boolean"; } - if (typeof value === "object") { - return "json"; - } return "string"; } @@ -986,26 +983,37 @@ function rowEditControlElement(control, autocompleteUrl) { return autocomplete; } +function columnTypeForContext(columnType) { + if (!columnType) { + return null; + } + return { + type: columnType.type, + config: columnType.config || {}, + }; +} + function columnFormControlContext(column, value, isPk, columnType, options) { var pageData = tablePageData(); - var hasDefault = - options.hasDefault || - (options.defaultValue !== null && typeof options.defaultValue !== "undefined"); + var hasSqliteDefault = + options.hasSqliteDefault || + (options.sqliteDefaultExpression !== null && + typeof options.sqliteDefaultExpression !== "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, + columnType: columnTypeForContext(columnType), sqliteType: options.sqliteType || null, notNull: !!options.notnull, isPrimaryKey: !!isPk, - readOnly: !!(isPk && options.primaryKeyReadonly !== false), - hasDefault: !!hasDefault, - defaultValue: options.defaultValue, + hasSqliteDefault: !!hasSqliteDefault, + sqliteDefaultExpression: options.sqliteDefaultExpression, + useSqliteDefaultInitially: !!( + hasSqliteDefault && options.useSqliteDefaultInitially + ), form: options.form || null, dialog: options.dialog || null, }; @@ -1018,6 +1026,148 @@ function makeColumnField(manager, context) { return manager.makeColumnField(context); } +function createColumnFieldApi(options) { + var control = options.control; + var context = options.context; + var field = { + context: context, + id: options.id, + labelId: options.labelId, + descriptionId: options.descriptionId, + root: null, + form: options.form || null, + dialog: options.dialog || null, + input: control, + control: control, + meta: options.meta || null, + validationMessageElement: null, + getValue: function () { + return valueFromRowEditControl(control); + }, + setValue: function (value, setOptions) { + setOptions = setOptions || {}; + if ( + value !== null && + typeof value !== "undefined" && + typeof value === "object" + ) { + throw new TypeError( + "field.setValue() accepts strings, numbers, booleans or null; serialize objects before setting the field value", + ); + } + field.stopUsingSqliteDefault({ dispatch: false }); + control.value = valueToEditText(value); + control.dataset.currentValueKind = rowEditValueKind(value); + if (setOptions.dispatch !== false) { + field.dispatchChange(); + } + }, + getInitialValue: function () { + return initialValueFromRowEditControl(control); + }, + hasChanged: function () { + if (field.isUsingSqliteDefault()) { + return !context.useSqliteDefaultInitially; + } + if ( + control.value === (control.dataset.initialValue || "") && + (control.dataset.currentValueKind || + control.dataset.initialValueKind || + "string") === (control.dataset.initialValueKind || "string") + ) { + return false; + } + try { + return !rowEditValuesMatch(field.getValue(), field.getInitialValue()); + } catch (_error) { + return true; + } + }, + clearValue: function (clearOptions) { + field.setValue(null, clearOptions); + }, + resetValue: function (resetOptions) { + resetOptions = resetOptions || {}; + field.stopUsingSqliteDefault({ dispatch: false }); + control.value = control.dataset.initialValue || ""; + control.dataset.currentValueKind = + control.dataset.initialValueKind || "string"; + if (resetOptions.dispatch !== false) { + field.dispatchChange(); + } + }, + isUsingSqliteDefault: function () { + return control.dataset.useSqliteDefault === "1"; + }, + useSqliteDefault: function (defaultOptions) { + defaultOptions = defaultOptions || {}; + if (!context.hasSqliteDefault) { + return; + } + control.dataset.useSqliteDefault = "1"; + control.disabled = true; + control.value = ""; + control.dataset.currentValueKind = "null"; + field.syncSqliteDefaultUi(); + if (defaultOptions.dispatch !== false) { + field.dispatchChange(); + } + }, + stopUsingSqliteDefault: function (defaultOptions) { + defaultOptions = defaultOptions || {}; + if (control.dataset.useSqliteDefault !== "1") { + return; + } + control.dataset.useSqliteDefault = "0"; + control.disabled = false; + field.syncSqliteDefaultUi(); + if (defaultOptions.dispatch !== false) { + field.dispatchChange(); + } + }, + syncSqliteDefaultUi: function () {}, + dispatchChange: function () { + control.dispatchEvent(new Event("input", { bubbles: true })); + control.dispatchEvent(new Event("change", { bubbles: true })); + }, + setValidity: function (message) { + message = message || ""; + control.setCustomValidity(message); + if (message) { + control.setAttribute("aria-invalid", "true"); + } else { + control.removeAttribute("aria-invalid"); + } + var validationMessage = ensureColumnFieldValidationMessage(field); + if (validationMessage) { + validationMessage.textContent = message; + validationMessage.hidden = !message; + } + }, + clearValidity: function () { + field.setValidity(""); + }, + }; + return field; +} + +function ensureColumnFieldValidationMessage(field) { + if (field.validationMessageElement) { + return field.validationMessageElement; + } + if (!field.meta) { + return null; + } + var validationMessage = document.createElement("span"); + validationMessage.id = field.id + "-validation-error"; + validationMessage.className = "row-edit-field-validation-error"; + validationMessage.hidden = true; + validationMessage.setAttribute("role", "alert"); + field.meta.appendChild(validationMessage); + field.validationMessageElement = validationMessage; + return validationMessage; +} + function renderColumnField(pluginControl, fieldApi) { if (!pluginControl || !pluginControl.render) { return null; @@ -1029,8 +1179,9 @@ function renderColumnField(pluginControl, fieldApi) { if (fieldApi.context.columnType && fieldApi.context.columnType.type) { pluginWrap.dataset.columnType = fieldApi.context.columnType.type; } + fieldApi.root = pluginWrap; try { - var rendered = pluginControl.render(pluginWrap, fieldApi); + var rendered = pluginControl.render(fieldApi); if (rendered && rendered.nodeType) { pluginWrap.appendChild(rendered); } @@ -1043,6 +1194,54 @@ function renderColumnField(pluginControl, fieldApi) { return pluginWrap; } +function validateJsonColumnField(field) { + var value = field.input.value; + if (value.trim() === "") { + field.clearValidity(); + return true; + } + try { + JSON.parse(value); + field.clearValidity(); + return true; + } catch (error) { + field.setValidity( + "Invalid JSON" + (error && error.message ? ": " + error.message : ""), + ); + return false; + } +} + +function registerBuiltinColumnFieldPlugins(manager) { + if (!manager || !manager.registerPlugin) { + return; + } + manager.registerPlugin("datasette-json-column", { + version: "1.0", + makeColumnField: function (context) { + if (!context.columnType || context.columnType.type !== "json") { + return null; + } + return { + inputType: "textarea", + render: function (field) { + field.input.addEventListener("input", function () { + validateJsonColumnField(field); + }); + field.input.addEventListener("change", function () { + validateJsonColumnField(field); + }); + validateJsonColumnField(field); + field.root.appendChild(field.input); + }, + focus: function (field) { + field.input.focus(); + }, + }; + }, + }); +} + function focusRowEditPluginControl(field) { var pluginWrap = field.querySelector(".row-edit-plugin-control"); if (!pluginWrap) { @@ -1051,8 +1250,12 @@ function focusRowEditPluginControl(field) { var pluginControl = pluginWrap._datasetteColumnField; var fieldApi = pluginWrap._datasetteColumnFormField; if (pluginControl && pluginControl.focus) { - pluginControl.focus(pluginWrap, fieldApi); - return true; + try { + pluginControl.focus(fieldApi); + return true; + } catch (error) { + console.error("Error focusing column form control", error); + } } return false; } @@ -1089,7 +1292,7 @@ function destroyRowEditFields(state) { var fieldApi = pluginWrap._datasetteColumnFormField; if (pluginControl && pluginControl.destroy) { try { - pluginControl.destroy(pluginWrap, fieldApi); + pluginControl.destroy(fieldApi); } catch (error) { console.error("Error destroying column form control", error); } @@ -1102,10 +1305,12 @@ function createRowEditField(column, value, isPk, columnType, index, options) { options = options || {}; var field = document.createElement("div"); field.className = "row-edit-field"; - var hasDefault = - options.hasDefault || - (options.defaultValue !== null && typeof options.defaultValue !== "undefined"); - var useDefaultInitially = hasDefault && options.useDefaultInitially; + var hasSqliteDefault = + options.hasSqliteDefault || + (options.sqliteDefaultExpression !== null && + typeof options.sqliteDefaultExpression !== "undefined"); + var useSqliteDefaultInitially = + hasSqliteDefault && options.useSqliteDefaultInitially; var fieldId = "row-edit-field-" + index; var metaId = "row-edit-field-meta-" + index; @@ -1136,12 +1341,15 @@ function createRowEditField(column, value, isPk, columnType, index, options) { control.name = column; control.value = valueToEditText(value); control.setAttribute("aria-describedby", metaId); - control.dataset.originalValue = valueToEditText(value); - control.dataset.originalValueType = - options.valueType || rowEditValueType(value); + control.dataset.initialValue = valueToEditText(value); + control.dataset.initialValueKind = + options.valueKind || rowEditValueKind(value); control.dataset.primaryKey = isPk ? "1" : "0"; - if (useDefaultInitially) { - control.dataset.useDefault = "1"; + control.dataset.currentValueKind = control.dataset.initialValueKind; + if (hasSqliteDefault) { + control.dataset.useSqliteDefault = useSqliteDefaultInitially ? "1" : "0"; + } + if (useSqliteDefaultInitially) { control.disabled = true; } if (options.omitIfBlank) { @@ -1172,8 +1380,8 @@ function createRowEditField(column, value, isPk, columnType, index, options) { if (options.notnull) { metaParts.push("Required"); } - if (hasDefault && !useDefaultInitially) { - metaParts.push("Default: " + options.defaultValue); + if (hasSqliteDefault && !useSqliteDefaultInitially) { + metaParts.push("SQLite default: " + options.sqliteDefaultExpression); } if (value === null) { metaParts.push("Current value: NULL"); @@ -1208,16 +1416,17 @@ function createRowEditField(column, value, isPk, columnType, index, options) { meta.appendChild(foreignKeyLinkWrap); updateRowEditFieldMetaHidden(meta); } - var fieldApi = { + var fieldApi = createColumnFieldApi({ id: fieldId, labelId: labelId, descriptionId: metaId, - input: control, control: control, + meta: meta, + input: control, form: options.form || null, dialog: options.dialog || null, context: context, - }; + }); var pluginControlElement = renderColumnField(pluginControl, fieldApi); var controlElement = pluginControlElement || rowEditControlElement(control, options.autocompleteUrl); @@ -1238,7 +1447,7 @@ function createRowEditField(column, value, isPk, columnType, index, options) { resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); } - if (useDefaultInitially) { + if (hasSqliteDefault) { var defaultBlock = document.createElement("div"); defaultBlock.className = "row-edit-default"; defaultBlock.setAttribute("aria-describedby", metaId); @@ -1248,7 +1457,7 @@ function createRowEditField(column, value, isPk, columnType, index, options) { defaultText.appendChild(document.createTextNode("default ")); var defaultCode = document.createElement("code"); defaultCode.className = "row-edit-default-code"; - defaultCode.textContent = options.defaultValue; + defaultCode.textContent = options.sqliteDefaultExpression; defaultText.appendChild(defaultCode); var setValueButton = document.createElement("button"); @@ -1262,35 +1471,37 @@ function createRowEditField(column, value, isPk, columnType, index, options) { customWrap.className = "row-edit-custom-value"; customWrap.hidden = true; - var useDefaultButton = document.createElement("button"); - useDefaultButton.type = "button"; - useDefaultButton.className = "row-edit-default-button"; - useDefaultButton.textContent = "Use default"; - useDefaultButton.setAttribute("aria-label", "Use default for " + column); + var useSqliteDefaultButton = document.createElement("button"); + useSqliteDefaultButton.type = "button"; + useSqliteDefaultButton.className = "row-edit-default-button"; + useSqliteDefaultButton.textContent = "Use default"; + useSqliteDefaultButton.setAttribute( + "aria-label", + "Use SQLite default for " + column, + ); setValueButton.addEventListener("click", function () { - control.dataset.useDefault = "0"; - control.disabled = false; - defaultBlock.hidden = true; - customWrap.hidden = false; + fieldApi.stopUsingSqliteDefault(); control.focus(); }); - useDefaultButton.addEventListener("click", function () { - control.dataset.useDefault = "1"; - control.disabled = true; - control.value = ""; - customWrap.hidden = true; - defaultBlock.hidden = false; + useSqliteDefaultButton.addEventListener("click", function () { + fieldApi.useSqliteDefault(); setValueButton.focus(); }); defaultBlock.appendChild(defaultText); defaultBlock.appendChild(setValueButton); customWrap.appendChild(controlElement); - customWrap.appendChild(useDefaultButton); + customWrap.appendChild(useSqliteDefaultButton); controlWrap.appendChild(defaultBlock); controlWrap.appendChild(customWrap); + fieldApi.syncSqliteDefaultUi = function () { + var usingDefault = fieldApi.isUsingSqliteDefault(); + defaultBlock.hidden = !usingDefault; + customWrap.hidden = usingDefault; + }; + fieldApi.syncSqliteDefaultUi(); } else { controlWrap.appendChild(controlElement); } @@ -1340,17 +1551,19 @@ function valueFromRowEditControl(control) { return valueFromRowEditText( control.name, value, - control.dataset.originalValueType || "string", + control.dataset.currentValueKind || + control.dataset.initialValueKind || + "string", ); } -function valueFromRowEditText(name, value, originalValueType) { +function valueFromRowEditText(name, value, initialValueKind) { var trimmed = value.trim(); - if (originalValueType === "null" && value === "") { + if (initialValueKind === "null" && value === "") { return null; } - if (originalValueType === "number") { + if (initialValueKind === "number") { if (trimmed === "") { return null; } @@ -1360,7 +1573,7 @@ function valueFromRowEditText(name, value, originalValueType) { } return numberValue; } - if (originalValueType === "boolean") { + if (initialValueKind === "boolean") { if (/^(true|1|yes)$/i.test(trimmed)) { return true; } @@ -1369,24 +1582,14 @@ function valueFromRowEditText(name, value, originalValueType) { } throw new Error(name + " must be true or false"); } - if (originalValueType === "json") { - if (trimmed === "") { - return null; - } - try { - return JSON.parse(value); - } catch (_error) { - throw new Error(name + " must be valid JSON"); - } - } return value; } -function originalValueFromRowEditControl(control) { +function initialValueFromRowEditControl(control) { return valueFromRowEditText( control.name, - control.dataset.originalValue || "", - control.dataset.originalValueType || "string", + control.dataset.initialValue || "", + control.dataset.initialValueKind || "string", ); } @@ -1414,19 +1617,32 @@ function collectRowFormValues(state) { ) { return; } - if (control.dataset.useDefault === "1") { + if (control.dataset.useSqliteDefault === "1") { return; } if (control.dataset.omitIfBlank === "1" && control.value === "") { return; } - var value = valueFromRowEditControl(control); if ( state.mode === "edit" && - rowEditValuesMatch(value, originalValueFromRowEditControl(control)) + control.value === (control.dataset.initialValue || "") && + (control.dataset.currentValueKind || + control.dataset.initialValueKind || + "string") === (control.dataset.initialValueKind || "string") ) { return; } + var value = valueFromRowEditControl(control); + if (state.mode === "edit") { + try { + if (rowEditValuesMatch(value, initialValueFromRowEditControl(control))) { + return; + } + } catch (_error) { + // If the original value cannot be parsed using the field's current + // type, treat the field as changed and submit the corrected value. + } + } values[control.name] = value; }); return values; @@ -1688,16 +1904,16 @@ function renderRowInsertFields(state, data) { { autocompleteUrl: foreignKeyAutocompleteUrl(column.name), dialog: state.dialog, - defaultValue: column.default, form: state.form, - hasDefault: column.has_default, + hasSqliteDefault: 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, + sqliteDefaultExpression: column.default, + sqliteType: column.sqlite_type, + useSqliteDefaultInitially: column.has_default, + valueKind: column.value_kind, }, ), ); @@ -2395,6 +2611,7 @@ function openColumnChooser() { document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; + registerBuiltinColumnFieldPlugins(manager); initializeColumnActions(manager); // Main table diff --git a/datasette/views/table.py b/datasette/views/table.py index dc2589e9..de7b0216 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -241,15 +241,18 @@ async def _validate_column_types(datasette, database_name, table_name, rows): return errors -def _column_value_type_for_insert_form(column_detail, column_type): - if column_type is not None and column_type.name == "json": - return "json" +def _column_value_kind_for_insert_form(column_detail): sqlite_type = SQLiteType.from_declared_type(column_detail.type) if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL): return "number" return "string" +def _column_sqlite_type_for_insert_form(column_detail): + sqlite_type = SQLiteType.from_declared_type(column_detail.type) + return sqlite_type.value if sqlite_type is not None else None + + async def _foreign_key_autocomplete_urls( datasette, request, db, database_name, table_name ): @@ -326,12 +329,12 @@ async def _table_insert_ui( columns.append( { "name": column.name, - "type": column.type, + "sqlite_type": _column_sqlite_type_for_insert_form(column), "notnull": column.notnull, "default": column.default_value, "has_default": column.default_value is not None, "is_pk": is_pk, - "value_type": _column_value_type_for_insert_form(column, column_type), + "value_kind": _column_value_kind_for_insert_form(column), "column_type": ( {"type": column_type.name, "config": column_type.config} if column_type is not None diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index d298b6d4..e1a889b4 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -198,13 +198,13 @@ 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. +It is designed for plugins that :ref:`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. +Datasette owns the value that will be submitted to the insert/update API. The ``context`` object describes the column and form environment; custom controls should read and write field values using the ``field`` helper object passed to ``render(field)``. Context object ^^^^^^^^^^^^^^ @@ -221,22 +221,26 @@ Context object The table name. ``tableUrl`` - string or null - The path to the table page, including any configured base URL prefix. + The path to the table page, including any configured :ref:`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. + The configured Datasette column type for this column, if one exists. This is ``null`` if no column type has been configured. + + If present, this object has exactly these keys: + + ``type`` - string + The :ref:`registered column type name `, matching the ``name`` attribute of the Python ``ColumnType`` subclass. + + ``config`` - object + Configuration for this specific column type assignment. This is ``{}`` if no configuration has been set. + + 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. + The normalized SQLite type for this column, if known. This is one of ``"TEXT"``, ``"INTEGER"``, ``"REAL"``, ``"BLOB"``, ``"NULL"`` or ``null`` if Datasette could not determine the type. ``notNull`` - boolean True if the column is defined as ``NOT NULL``. @@ -244,14 +248,14 @@ Context object ``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 +``hasSqliteDefault`` - 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. +``sqliteDefaultExpression`` + The SQLite default expression for the column, if available. This is the expression from the table schema, not the actual value SQLite will insert. + +``useSqliteDefaultInitially`` - boolean + True if the insert form should initially omit this column so SQLite uses the column default. ``form`` - ``HTMLFormElement`` or null The row insert/edit form element. @@ -267,15 +271,15 @@ A plugin that wants to handle a field should return an object. Datasette current ``inputType`` - string, optional If set to ``"textarea"``, Datasette creates a ``