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.
This commit is contained in:
Simon Willison 2026-06-14 13:54:10 -07:00
commit 841a2536ea
6 changed files with 957 additions and 139 deletions

View file

@ -0,0 +1,515 @@
import json
from pathlib import Path
import subprocess
import textwrap
STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static"
def test_datasette_manager_make_column_field():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const datasetteManagerJs = __DATASETTE_MANAGER_JS__;
const documentListeners = {};
global.CustomEvent = class {
constructor(name, options) {
this.type = name;
this.detail = options ? options.detail : undefined;
}
};
global.document = {
addEventListener(name, callback) {
documentListeners[name] = documentListeners[name] || [];
documentListeners[name].push(callback);
},
dispatchEvent(event) {
for (const callback of documentListeners[event.type] || []) {
callback(event);
}
},
};
global.window = { datasetteVersion: "test" };
vm.runInThisContext(
fs.readFileSync(datasetteManagerJs, "utf8"),
{ filename: "datasette-manager.js" }
);
for (const callback of documentListeners.DOMContentLoaded || []) {
callback();
}
window.__DATASETTE__.registerPlugin("declines", {
makeColumnField() {
return null;
},
});
window.__DATASETTE__.registerPlugin("handles", {
makeColumnField(context) {
if (context.columnType.type !== "demo") {
return null;
}
return { inputType: "textarea" };
},
});
const control = window.__DATASETTE__.makeColumnField({
column: "body",
columnType: { type: "demo", config: null },
});
console.log(JSON.stringify(control));
""").replace(
"__DATASETTE_MANAGER_JS__",
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
)
result = subprocess.run(
["node", "-e", script],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"pluginName": "handles",
"inputType": "textarea",
}
def test_table_plugin_column_field_api():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const tableJs = __TABLE_JS__;
class FakeEvent {
constructor(type, options) {
this.type = type;
this.bubbles = !!(options && options.bubbles);
}
}
class FakeElement {
constructor(tagName = "div") {
this.nodeName = tagName.toUpperCase();
this.nodeType = 1;
this.children = [];
this.dataset = {};
this.attributes = {};
this.value = "";
this.name = "";
this.disabled = false;
this.readOnly = false;
this.dispatchedEvents = [];
this.eventListeners = {};
this.validationMessage = "";
this.hidden = false;
this.textContent = "";
this.className = "";
this.classList = {
add: (...names) => {
const classes = new Set(this.className.split(/\\s+/).filter(Boolean));
for (const name of names) {
classes.add(name);
}
this.className = Array.from(classes).join(" ");
},
remove: (...names) => {
const removeNames = new Set(names);
this.className = this.className
.split(/\\s+/)
.filter((name) => name && !removeNames.has(name))
.join(" ");
},
contains: (name) => this.className.split(/\\s+/).includes(name),
};
}
appendChild(child) {
this.children.push(child);
child.parentNode = this;
return child;
}
addEventListener(type, callback) {
this.eventListeners[type] = this.eventListeners[type] || [];
this.eventListeners[type].push(callback);
}
dispatchEvent(event) {
event.target = event.target || this;
this.dispatchedEvents.push(event.type);
for (const callback of this.eventListeners[event.type] || []) {
callback(event);
}
return true;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name] || null;
}
removeAttribute(name) {
delete this.attributes[name];
}
setCustomValidity(message) {
this.validationMessage = message;
}
}
global.Event = FakeEvent;
global.document = {
addEventListener() {},
createElement(tagName) {
return new FakeElement(tagName);
},
createTextNode(text) {
const node = new FakeElement("#text");
node.textContent = text;
return node;
},
};
global.location = {
href: "http://localhost/data/projects",
pathname: "/data/projects",
search: "",
};
global.window = {
_datasetteTableData: {
database: "data",
table: "projects",
tableUrl: "/data/projects",
},
};
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
filename: "table.js",
});
const context = columnFormControlContext(
"logo",
"df-old",
false,
{ type: "file", config: null },
{
mode: "edit",
hasSqliteDefault: true,
sqliteDefaultExpression: "lower(hex(randomblob(4)))",
useSqliteDefaultInitially: true,
}
);
if ("defaultValue" in context) {
throw new Error("context should not expose defaultValue");
}
if (!context.hasSqliteDefault) {
throw new Error("context.hasSqliteDefault should be true");
}
if (context.sqliteDefaultExpression !== "lower(hex(randomblob(4)))") {
throw new Error("context.sqliteDefaultExpression was not set");
}
if (JSON.stringify(context.columnType) !== '{"type":"file","config":{}}') {
throw new Error("context.columnType should expose type and object config");
}
if ("value" in context || "valueType" in context) {
throw new Error("context should not expose value state");
}
if ("initialValue" in context || "initialValueKind" in context) {
throw new Error("context should not expose initial value state");
}
const control = new FakeElement("input");
control.name = "logo";
control.value = "df-old";
control.dataset.initialValue = "df-old";
control.dataset.initialValueKind = "string";
control.dataset.currentValueKind = "string";
control.dataset.useSqliteDefault = "1";
control.disabled = true;
const field = createColumnFieldApi({
id: "row-edit-field-0",
labelId: "row-edit-field-label-0",
descriptionId: "row-edit-field-meta-0",
control,
meta: new FakeElement("span"),
context,
});
let renderArgumentCount = null;
let renderField = null;
const wrapper = renderColumnField(
{
pluginName: "test-plugin",
render(field) {
renderArgumentCount = arguments.length;
renderField = field;
field.root.appendChild(document.createElement("button"));
},
},
field
);
if (renderArgumentCount !== 1 || renderField !== field) {
throw new Error("plugin render should receive the field object only");
}
if (field.root !== wrapper) {
throw new Error("field.root should be the plugin wrapper");
}
field.setValue(null);
if (field.getValue() !== null) {
throw new Error("field.setValue(null) should round-trip as null");
}
if (field.isUsingSqliteDefault()) {
throw new Error("field.setValue() should stop using the SQLite default");
}
if (control.dataset.currentValueKind !== "null") {
throw new Error("null values should update currentValueKind");
}
field.setValue("df-new");
if (field.getValue() !== "df-new") {
throw new Error("field.setValue() should update the current value");
}
if (field.getInitialValue() !== "df-old") {
throw new Error("field.getInitialValue() should remain stable");
}
if (!field.hasChanged()) {
throw new Error("field.hasChanged() should notice plugin value changes");
}
if (control.dispatchedEvents.join(",") !== "input,change,input,change") {
throw new Error(`Unexpected dispatched events: ${control.dispatchedEvents}`);
}
try {
field.setValue({ id: "df-object" });
throw new Error("field.setValue() should reject object values");
} catch (error) {
if (!String(error.message).includes("serialize objects")) {
throw error;
}
}
field.setValidity("Pick a file");
if (control.validationMessage !== "Pick a file") {
throw new Error("field.setValidity() should set custom validity");
}
if (control.getAttribute("aria-invalid") !== "true") {
throw new Error("field.setValidity() should set aria-invalid");
}
if (!field.validationMessageElement || field.validationMessageElement.hidden) {
throw new Error("field.setValidity() should show a field validation message");
}
field.clearValidity();
if (control.validationMessage !== "" || control.getAttribute("aria-invalid") !== null) {
throw new Error("field.clearValidity() should clear custom validity");
}
field.useSqliteDefault();
if (!field.isUsingSqliteDefault() || !control.disabled) {
throw new Error("field.useSqliteDefault() should mark and disable control");
}
process.stdout.write("ok");
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
result = subprocess.run(
["node", "-e", script],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout == "ok"
def test_builtin_json_column_field_validation():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const tableJs = __TABLE_JS__;
class FakeEvent {
constructor(type, options) {
this.type = type;
this.bubbles = !!(options && options.bubbles);
}
}
class FakeElement {
constructor(tagName = "div") {
this.nodeName = tagName.toUpperCase();
this.nodeType = 1;
this.children = [];
this.dataset = {};
this.attributes = {};
this.value = "";
this.name = "";
this.disabled = false;
this.hidden = false;
this.textContent = "";
this.validationMessage = "";
this.eventListeners = {};
this.className = "";
}
appendChild(child) {
this.children.push(child);
child.parentNode = this;
return child;
}
addEventListener(type, callback) {
this.eventListeners[type] = this.eventListeners[type] || [];
this.eventListeners[type].push(callback);
}
dispatchEvent(event) {
event.target = event.target || this;
for (const callback of this.eventListeners[event.type] || []) {
callback(event);
}
return true;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name] || null;
}
removeAttribute(name) {
delete this.attributes[name];
}
setCustomValidity(message) {
this.validationMessage = message;
}
}
global.Event = FakeEvent;
global.document = {
addEventListener() {},
createElement(tagName) {
return new FakeElement(tagName);
},
createTextNode(text) {
const node = new FakeElement("#text");
node.textContent = text;
return node;
},
};
global.location = {
href: "http://localhost/data/projects",
pathname: "/data/projects",
search: "",
};
global.window = {
_datasetteTableData: {
database: "data",
table: "projects",
tableUrl: "/data/projects",
},
};
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
filename: "table.js",
});
const plugins = [];
registerBuiltinColumnFieldPlugins({
registerPlugin(name, plugin) {
plugins.push({ name, plugin });
},
});
const jsonPlugin = plugins.find((entry) => entry.name === "datasette-json-column");
if (!jsonPlugin) {
throw new Error("datasette-json-column plugin was not registered");
}
const pluginControl = jsonPlugin.plugin.makeColumnField({
column: "metadata",
columnType: { type: "json", config: {} },
});
if (!pluginControl || pluginControl.inputType !== "textarea") {
throw new Error("JSON column plugin should request a textarea");
}
const context = columnFormControlContext(
"metadata",
'{"ok": true}',
false,
{ type: "json", config: {} },
{ mode: "edit" }
);
const control = new FakeElement("textarea");
control.name = "metadata";
control.value = '{"ok": true}';
control.dataset.initialValue = '{"ok": true}';
control.dataset.initialValueKind = "string";
control.dataset.currentValueKind = "string";
const meta = new FakeElement("span");
const field = createColumnFieldApi({
id: "row-edit-field-0",
labelId: "row-edit-field-label-0",
descriptionId: "row-edit-field-meta-0",
control,
meta,
context,
});
renderColumnField(
Object.assign({ pluginName: "datasette-json-column" }, pluginControl),
field
);
if (control.validationMessage !== "") {
throw new Error("Initial valid JSON should not be invalid");
}
if (control.dataset.initialValueKind !== "string") {
throw new Error("JSON plugin should keep the original string value kind");
}
if (control.dataset.currentValueKind !== "string") {
throw new Error("JSON plugin should keep the current string value kind");
}
if (!field.validationMessageElement || field.validationMessageElement.hidden !== true) {
throw new Error("JSON validation message should start hidden");
}
control.value = "{";
control.dispatchEvent(new Event("input", { bubbles: true }));
if (!control.validationMessage.startsWith("Invalid JSON")) {
throw new Error("Invalid JSON should set a custom validity message");
}
if (control.getAttribute("aria-invalid") !== "true") {
throw new Error("Invalid JSON should set aria-invalid");
}
if (field.validationMessageElement.hidden) {
throw new Error("Invalid JSON should show the validation message");
}
control.value = '{"ok": true}';
control.dispatchEvent(new Event("input", { bubbles: true }));
if (control.validationMessage !== "") {
throw new Error("Valid JSON should clear the custom validity message");
}
if (control.getAttribute("aria-invalid") !== null) {
throw new Error("Valid JSON should clear aria-invalid");
}
if (!field.validationMessageElement.hidden) {
throw new Error("Valid JSON should hide the validation message");
}
control.dataset.initialValue = '{"ok":';
control.value = '{"ok": true}';
const values = collectRowFormValues({
mode: "edit",
fields: {
querySelectorAll() {
return [control];
},
},
});
if (values.metadata !== '{"ok": true}') {
throw new Error("Corrected JSON should be submitted as a string value");
}
process.stdout.write("ok");
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
result = subprocess.run(
["node", "-e", script],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout == "ok"

View file

@ -990,14 +990,18 @@ async def test_table_insert_action_button_and_data():
]
name, score, created, body = insert_data["columns"]
assert name["notnull"] == 1
assert name["value_type"] == "string"
assert name["sqlite_type"] == "TEXT"
assert name["value_kind"] == "string"
assert not name["has_default"]
assert score["default"] == "5"
assert score["has_default"]
assert score["value_type"] == "number"
assert score["sqlite_type"] == "INTEGER"
assert score["value_kind"] == "number"
assert created["default"] == "datetime('now')"
assert created["has_default"]
assert body["value_type"] == "string"
assert created["sqlite_type"] == "TEXT"
assert body["sqlite_type"] == "TEXT"
assert body["value_kind"] == "string"
assert body["column_type"] == {"type": "textarea", "config": None}
finally:
ds.close()