Port edit tools field API test to Playwright

Refs #2779
This commit is contained in:
Simon Willison 2026-06-14 16:48:32 -07:00
commit 3cfdca026a
2 changed files with 329 additions and 387 deletions

View file

@ -6,393 +6,6 @@ import textwrap
STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static"
def test_table_plugin_column_field_api():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const editToolsJs = __EDIT_TOOLS_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(editToolsJs, "utf8"), {
filename: "edit-tools.js",
});
const context = columnFormControlContext(
"logo",
true,
{ type: "file", config: null },
{
mode: "edit",
defaultExpression: "lower(hex(randomblob(4)))",
useSqliteDefault: true,
}
);
const expectedContextKeys = [
"mode",
"database",
"table",
"tableUrl",
"column",
"columnType",
"sqliteType",
"notNull",
"isPk",
"defaultExpression",
"form",
"dialog",
].join(",");
if (Object.keys(context).join(",") !== expectedContextKeys) {
throw new Error(`Unexpected context keys: ${Object.keys(context).join(",")}`);
}
if (context.defaultExpression !== "lower(hex(randomblob(4)))") {
throw new Error("context.defaultExpression was not set");
}
if (JSON.stringify(context.columnType) !== '{"type":"file","config":{}}') {
throw new Error("context.columnType should expose type and object config");
}
if (!context.isPk) {
throw new Error("context.isPk should say whether the column is a primary key");
}
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;
return 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");
}
if (wrapper.children.length !== 1 || wrapper.children[0].nodeName !== "BUTTON") {
throw new Error("plugin render should append returned DOM nodes to field.root");
}
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.length !== 0) {
throw new Error(`field.setValue() should not dispatch events: ${control.dispatchedEvents}`);
}
const dirtyRowField = new FakeElement("div");
dirtyRowField._datasetteColumnFormField = field;
const dirtyState = {
hasLoaded: true,
isLoading: false,
isSaving: false,
mode: "edit",
fields: {
querySelectorAll(selector) {
return selector === ".row-edit-field" ? [dirtyRowField] : [];
},
},
dialog: {
closeCalled: false,
close() {
this.closeCalled = true;
},
},
shouldRestoreFocus: false,
};
const confirmMessages = [];
window.confirm = (message) => {
confirmMessages.push(message);
return false;
};
if (!rowEditDialogHasChanges(dirtyState)) {
throw new Error("row edit dialog should notice changed field values");
}
if (closeRowEditDialogIfConfirmed(dirtyState)) {
throw new Error("dirty row edit dialog should stay open when discard is rejected");
}
if (dirtyState.dialog.closeCalled) {
throw new Error("dirty row edit dialog should not close when discard is rejected");
}
if (confirmMessages[0] !== "Discard unsaved changes to this row?") {
throw new Error(`Unexpected discard confirmation: ${confirmMessages[0]}`);
}
dirtyState.mode = "insert";
window.confirm = (message) => {
confirmMessages.push(message);
return true;
};
if (!closeRowEditDialogIfConfirmed(dirtyState)) {
throw new Error("dirty row edit dialog should close when discard is confirmed");
}
if (!dirtyState.dialog.closeCalled || !dirtyState.shouldRestoreFocus) {
throw new Error("confirmed dirty row edit dialog should close and restore focus");
}
if (confirmMessages[1] !== "Discard this new row?") {
throw new Error(`Unexpected insert discard confirmation: ${confirmMessages[1]}`);
}
const cleanContext = columnFormControlContext(
"title",
false,
null,
{ mode: "edit" }
);
if (cleanContext.defaultExpression !== null) {
throw new Error("context.defaultExpression should be null without a SQLite default");
}
const cleanControl = new FakeElement("input");
cleanControl.name = "title";
cleanControl.value = "clean";
cleanControl.dataset.initialValue = "clean";
cleanControl.dataset.initialValueKind = "string";
cleanControl.dataset.currentValueKind = "string";
const cleanField = createColumnFieldApi({
id: "row-edit-field-1",
labelId: "row-edit-field-label-1",
descriptionId: "row-edit-field-meta-1",
control: cleanControl,
meta: new FakeElement("span"),
context: cleanContext,
});
const cleanRowField = new FakeElement("div");
cleanRowField._datasetteColumnFormField = cleanField;
const cleanState = {
hasLoaded: true,
isLoading: false,
isSaving: false,
mode: "edit",
fields: {
querySelectorAll(selector) {
return selector === ".row-edit-field" ? [cleanRowField] : [];
},
},
dialog: {
closeCalled: false,
close() {
this.closeCalled = true;
},
},
shouldRestoreFocus: false,
};
confirmMessages.length = 0;
window.confirm = (message) => {
confirmMessages.push(message);
return false;
};
if (rowEditDialogHasChanges(cleanState)) {
throw new Error("row edit dialog should ignore unchanged field values");
}
if (!closeRowEditDialogIfConfirmed(cleanState)) {
throw new Error("clean row edit dialog should close without confirmation");
}
if (!cleanState.dialog.closeCalled || !cleanState.shouldRestoreFocus) {
throw new Error("clean row edit dialog should close and restore focus");
}
if (confirmMessages.length !== 0) {
throw new Error("clean row edit dialog should not ask for confirmation");
}
dirtyState.dialog.closeCalled = false;
dirtyState.shouldRestoreFocus = false;
confirmMessages.length = 0;
field.setValue("<p></p>");
field.markClean();
if (field.hasChanged()) {
throw new Error("field.markClean() should update the clean baseline");
}
if (rowEditDialogHasChanges(dirtyState)) {
throw new Error("row edit dialog should ignore clean plugin normalization");
}
if (!closeRowEditDialogIfConfirmed(dirtyState)) {
throw new Error("normalized row edit dialog should close without confirmation");
}
if (confirmMessages.length !== 0) {
throw new Error("normalized row edit dialog should not ask for confirmation");
}
field.setValue("<p>Hello</p>");
if (!field.hasChanged() || !rowEditDialogHasChanges(dirtyState)) {
throw new Error("later plugin value changes should still count as dirty");
}
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("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.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");

View file

@ -79,6 +79,20 @@ def test_datasette_homepage_contains_datasette(page, datasette_server):
assert "Datasette" in page.locator("body").inner_text()
def load_edit_tools(page, datasette_server):
page.goto(datasette_server)
page.evaluate("""
() => {
window._datasetteTableData = {
database: "data",
table: "projects",
tableUrl: "/data/projects",
};
}
""")
page.add_script_tag(url=f"{datasette_server}-/static/edit-tools.js")
@pytest.mark.playwright
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
page.goto(datasette_server)
@ -213,3 +227,318 @@ def test_datasette_manager_make_column_field(page, datasette_server):
"pluginName": "handles",
"useTextarea": True,
}
@pytest.mark.playwright
def test_table_plugin_column_field_api(page, datasette_server):
load_edit_tools(page, datasette_server)
page.evaluate("""
() => {
const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};
const context = columnFormControlContext(
"logo",
true,
{ type: "file", config: null },
{
mode: "edit",
defaultExpression: "lower(hex(randomblob(4)))",
useSqliteDefault: true,
},
);
const expectedContextKeys = [
"mode",
"database",
"table",
"tableUrl",
"column",
"columnType",
"sqliteType",
"notNull",
"isPk",
"defaultExpression",
"form",
"dialog",
].join(",");
assert(
Object.keys(context).join(",") === expectedContextKeys,
`Unexpected context keys: ${Object.keys(context).join(",")}`,
);
assert(
context.defaultExpression === "lower(hex(randomblob(4)))",
"context.defaultExpression was not set",
);
assert(
JSON.stringify(context.columnType) === '{"type":"file","config":{}}',
"context.columnType should expose type and object config",
);
assert(
context.isPk,
"context.isPk should say whether the column is a primary key",
);
const control = document.createElement("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 dispatchedEvents = [];
control.addEventListener("input", (event) =>
dispatchedEvents.push(event.type),
);
control.addEventListener("change", (event) =>
dispatchedEvents.push(event.type),
);
const field = createColumnFieldApi({
id: "row-edit-field-0",
labelId: "row-edit-field-label-0",
descriptionId: "row-edit-field-meta-0",
control,
meta: document.createElement("span"),
context,
});
let renderArgumentCount = null;
let renderField = null;
const wrapper = renderColumnField(
{
pluginName: "test-plugin",
render(field) {
renderArgumentCount = arguments.length;
renderField = field;
return document.createElement("button");
},
},
field,
);
assert(
renderArgumentCount === 1 && renderField === field,
"plugin render should receive the field object only",
);
assert(field.root === wrapper, "field.root should be the plugin wrapper");
assert(
wrapper.children.length === 1 &&
wrapper.children[0].nodeName === "BUTTON",
"plugin render should append returned DOM nodes to field.root",
);
field.setValue(null);
assert(field.getValue() === null, "field.setValue(null) should round-trip as null");
assert(
!field.isUsingSqliteDefault(),
"field.setValue() should stop using the SQLite default",
);
assert(
control.dataset.currentValueKind === "null",
"null values should update currentValueKind",
);
field.setValue("df-new");
assert(
field.getValue() === "df-new",
"field.setValue() should update the current value",
);
assert(
field.getInitialValue() === "df-old",
"field.getInitialValue() should remain stable",
);
assert(
field.hasChanged(),
"field.hasChanged() should notice plugin value changes",
);
assert(
dispatchedEvents.length === 0,
`field.setValue() should not dispatch events: ${dispatchedEvents}`,
);
const dirtyRowField = document.createElement("div");
dirtyRowField.className = "row-edit-field";
dirtyRowField._datasetteColumnFormField = field;
const dirtyFields = document.createElement("div");
dirtyFields.appendChild(dirtyRowField);
const dirtyState = {
hasLoaded: true,
isLoading: false,
isSaving: false,
mode: "edit",
fields: dirtyFields,
dialog: {
closeCalled: false,
close() {
this.closeCalled = true;
},
},
shouldRestoreFocus: false,
};
const confirmMessages = [];
window.confirm = (message) => {
confirmMessages.push(message);
return false;
};
assert(
rowEditDialogHasChanges(dirtyState),
"row edit dialog should notice changed field values",
);
assert(
!closeRowEditDialogIfConfirmed(dirtyState),
"dirty row edit dialog should stay open when discard is rejected",
);
assert(
!dirtyState.dialog.closeCalled,
"dirty row edit dialog should not close when discard is rejected",
);
assert(
confirmMessages[0] === "Discard unsaved changes to this row?",
`Unexpected discard confirmation: ${confirmMessages[0]}`,
);
dirtyState.mode = "insert";
window.confirm = (message) => {
confirmMessages.push(message);
return true;
};
assert(
closeRowEditDialogIfConfirmed(dirtyState),
"dirty row edit dialog should close when discard is confirmed",
);
assert(
dirtyState.dialog.closeCalled && dirtyState.shouldRestoreFocus,
"confirmed dirty row edit dialog should close and restore focus",
);
assert(
confirmMessages[1] === "Discard this new row?",
`Unexpected insert discard confirmation: ${confirmMessages[1]}`,
);
const cleanContext = columnFormControlContext("title", false, null, {
mode: "edit",
});
assert(
cleanContext.defaultExpression === null,
"context.defaultExpression should be null without a SQLite default",
);
const cleanControl = document.createElement("input");
cleanControl.name = "title";
cleanControl.value = "clean";
cleanControl.dataset.initialValue = "clean";
cleanControl.dataset.initialValueKind = "string";
cleanControl.dataset.currentValueKind = "string";
const cleanField = createColumnFieldApi({
id: "row-edit-field-1",
labelId: "row-edit-field-label-1",
descriptionId: "row-edit-field-meta-1",
control: cleanControl,
meta: document.createElement("span"),
context: cleanContext,
});
const cleanRowField = document.createElement("div");
cleanRowField.className = "row-edit-field";
cleanRowField._datasetteColumnFormField = cleanField;
const cleanFields = document.createElement("div");
cleanFields.appendChild(cleanRowField);
const cleanState = {
hasLoaded: true,
isLoading: false,
isSaving: false,
mode: "edit",
fields: cleanFields,
dialog: {
closeCalled: false,
close() {
this.closeCalled = true;
},
},
shouldRestoreFocus: false,
};
confirmMessages.length = 0;
window.confirm = (message) => {
confirmMessages.push(message);
return false;
};
assert(
!rowEditDialogHasChanges(cleanState),
"row edit dialog should ignore unchanged field values",
);
assert(
closeRowEditDialogIfConfirmed(cleanState),
"clean row edit dialog should close without confirmation",
);
assert(
cleanState.dialog.closeCalled && cleanState.shouldRestoreFocus,
"clean row edit dialog should close and restore focus",
);
assert(
confirmMessages.length === 0,
"clean row edit dialog should not ask for confirmation",
);
dirtyState.dialog.closeCalled = false;
dirtyState.shouldRestoreFocus = false;
confirmMessages.length = 0;
field.setValue("<p></p>");
field.markClean();
assert(
!field.hasChanged(),
"field.markClean() should update the clean baseline",
);
assert(
!rowEditDialogHasChanges(dirtyState),
"row edit dialog should ignore clean plugin normalization",
);
assert(
closeRowEditDialogIfConfirmed(dirtyState),
"normalized row edit dialog should close without confirmation",
);
assert(
confirmMessages.length === 0,
"normalized row edit dialog should not ask for confirmation",
);
field.setValue("<p>Hello</p>");
assert(
field.hasChanged() && rowEditDialogHasChanges(dirtyState),
"later plugin value changes should still count as dirty",
);
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");
assert(
control.validationMessage === "Pick a file",
"field.setValidity() should set custom validity",
);
assert(
control.getAttribute("aria-invalid") === "true",
"field.setValidity() should set aria-invalid",
);
assert(
field.validationMessageElement && !field.validationMessageElement.hidden,
"field.setValidity() should show a field validation message",
);
field.clearValidity();
assert(
control.validationMessage === "" &&
control.getAttribute("aria-invalid") === null,
"field.clearValidity() should clear custom validity",
);
field.useSqliteDefault();
assert(
field.isUsingSqliteDefault() && control.disabled,
"field.useSqliteDefault() should mark and disable control",
);
}
""")