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; }, }); window.__DATASETTE__.registerPlugin("handles", { makeColumnField(context) { if (context.columnType.type !== "demo") { return; } return { useTextarea: true }; }, }); 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", "useTextarea": True, } 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("

"); 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("

Hello

"); 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"); 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.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(editToolsJs, "utf8"), { filename: "edit-tools.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.useTextarea !== true) { throw new Error("JSON column plugin should request a textarea"); } const context = columnFormControlContext( "metadata", 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("__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"