diff --git a/tests/test_datasette_manager_js.py b/tests/test_datasette_manager_js.py index 1a46bb42..9022d26f 100644 --- a/tests/test_datasette_manager_js.py +++ b/tests/test_datasette_manager_js.py @@ -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("

"); - 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"); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 353f1d94..06ae826d 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -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("

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

Hello

"); + 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", + ); + } + """)