mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 13:36:58 +02:00
Refine column field plugin API and documentation
- Simplify JavaScript column field context: - expose `isPk` instead of `isPrimaryKey` - expose `defaultExpression` instead of separate SQLite default flags - remove value/default state from plugin context - Update field helper behavior: - `setValue()` no longer dispatches input/change events - remove dispatch options and `resetValue()` - add `markClean()` for plugin-normalized initial values - track clean field state for reliable dirty detection Also: - Prompt before closing row insert/edit dialogs when there are unsaved changes - Map declared SQLite types to affinities, returning `BLOB` for typeless columns and `NUMERIC` for numeric/date/boolean-like declarations
This commit is contained in:
parent
c083e44561
commit
3f7d389caf
6 changed files with 392 additions and 244 deletions
|
|
@ -6,19 +6,17 @@ class SQLiteType(Enum):
|
|||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NULL = "NULL"
|
||||
NUMERIC = "NUMERIC"
|
||||
|
||||
@classmethod
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
|
||||
if declared_type is None:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
|
|
@ -31,7 +29,7 @@ class SQLiteType(Enum):
|
|||
):
|
||||
return cls.REAL
|
||||
|
||||
return None
|
||||
return cls.NUMERIC
|
||||
|
||||
|
||||
class ColumnType:
|
||||
|
|
|
|||
|
|
@ -993,12 +993,16 @@ function columnTypeForContext(columnType) {
|
|||
};
|
||||
}
|
||||
|
||||
function columnFormControlContext(column, value, isPk, columnType, options) {
|
||||
function defaultExpressionForContext(expression) {
|
||||
if (expression === null || typeof expression === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
|
||||
function columnFormControlContext(column, isPk, columnType, options) {
|
||||
var pageData = tablePageData();
|
||||
var hasSqliteDefault =
|
||||
options.hasSqliteDefault ||
|
||||
(options.sqliteDefaultExpression !== null &&
|
||||
typeof options.sqliteDefaultExpression !== "undefined");
|
||||
var defaultExpression = defaultExpressionForContext(options.defaultExpression);
|
||||
return {
|
||||
mode: options.mode || "edit",
|
||||
database: pageData.database || null,
|
||||
|
|
@ -1008,12 +1012,8 @@ function columnFormControlContext(column, value, isPk, columnType, options) {
|
|||
columnType: columnTypeForContext(columnType),
|
||||
sqliteType: options.sqliteType || null,
|
||||
notNull: !!options.notnull,
|
||||
isPrimaryKey: !!isPk,
|
||||
hasSqliteDefault: !!hasSqliteDefault,
|
||||
sqliteDefaultExpression: options.sqliteDefaultExpression,
|
||||
useSqliteDefaultInitially: !!(
|
||||
hasSqliteDefault && options.useSqliteDefaultInitially
|
||||
),
|
||||
isPk: !!isPk,
|
||||
defaultExpression: defaultExpression,
|
||||
form: options.form || null,
|
||||
dialog: options.dialog || null,
|
||||
};
|
||||
|
|
@ -1044,8 +1044,7 @@ function createColumnFieldApi(options) {
|
|||
getValue: function () {
|
||||
return valueFromRowEditControl(control);
|
||||
},
|
||||
setValue: function (value, setOptions) {
|
||||
setOptions = setOptions || {};
|
||||
setValue: function (value) {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value !== "undefined" &&
|
||||
|
|
@ -1055,53 +1054,27 @@ function createColumnFieldApi(options) {
|
|||
"field.setValue() accepts strings, numbers, booleans or null; serialize objects before setting the field value",
|
||||
);
|
||||
}
|
||||
field.stopUsingSqliteDefault({ dispatch: false });
|
||||
field.stopUsingSqliteDefault();
|
||||
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;
|
||||
}
|
||||
return rowEditControlHasChanged(control);
|
||||
},
|
||||
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();
|
||||
}
|
||||
clearValue: function () {
|
||||
field.setValue(null);
|
||||
},
|
||||
isUsingSqliteDefault: function () {
|
||||
return control.dataset.useSqliteDefault === "1";
|
||||
},
|
||||
useSqliteDefault: function (defaultOptions) {
|
||||
defaultOptions = defaultOptions || {};
|
||||
if (!context.hasSqliteDefault) {
|
||||
useSqliteDefault: function () {
|
||||
if (
|
||||
context.defaultExpression === null ||
|
||||
typeof context.defaultExpression === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
control.dataset.useSqliteDefault = "1";
|
||||
|
|
@ -1109,26 +1082,18 @@ function createColumnFieldApi(options) {
|
|||
control.value = "";
|
||||
control.dataset.currentValueKind = "null";
|
||||
field.syncSqliteDefaultUi();
|
||||
if (defaultOptions.dispatch !== false) {
|
||||
field.dispatchChange();
|
||||
}
|
||||
},
|
||||
stopUsingSqliteDefault: function (defaultOptions) {
|
||||
defaultOptions = defaultOptions || {};
|
||||
stopUsingSqliteDefault: function () {
|
||||
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 }));
|
||||
markClean: function () {
|
||||
markRowEditControlClean(control);
|
||||
},
|
||||
setValidity: function (message) {
|
||||
message = message || "";
|
||||
|
|
@ -1148,6 +1113,7 @@ function createColumnFieldApi(options) {
|
|||
field.setValidity("");
|
||||
},
|
||||
};
|
||||
field.markClean();
|
||||
return field;
|
||||
}
|
||||
|
||||
|
|
@ -1220,10 +1186,10 @@ function registerBuiltinColumnFieldPlugins(manager) {
|
|||
version: "1.0",
|
||||
makeColumnField: function (context) {
|
||||
if (!context.columnType || context.columnType.type !== "json") {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
return {
|
||||
inputType: "textarea",
|
||||
useTextarea: true,
|
||||
render: function (field) {
|
||||
field.input.addEventListener("input", function () {
|
||||
validateJsonColumnField(field);
|
||||
|
|
@ -1232,7 +1198,7 @@ function registerBuiltinColumnFieldPlugins(manager) {
|
|||
validateJsonColumnField(field);
|
||||
});
|
||||
validateJsonColumnField(field);
|
||||
field.root.appendChild(field.input);
|
||||
return field.input;
|
||||
},
|
||||
focus: function (field) {
|
||||
field.input.focus();
|
||||
|
|
@ -1305,12 +1271,9 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
options = options || {};
|
||||
var field = document.createElement("div");
|
||||
field.className = "row-edit-field";
|
||||
var hasSqliteDefault =
|
||||
options.hasSqliteDefault ||
|
||||
(options.sqliteDefaultExpression !== null &&
|
||||
typeof options.sqliteDefaultExpression !== "undefined");
|
||||
var useSqliteDefaultInitially =
|
||||
hasSqliteDefault && options.useSqliteDefaultInitially;
|
||||
var defaultExpression = defaultExpressionForContext(options.defaultExpression);
|
||||
var hasDefaultExpression = defaultExpression !== null;
|
||||
var useSqliteDefault = hasDefaultExpression && options.useSqliteDefault;
|
||||
|
||||
var fieldId = "row-edit-field-" + index;
|
||||
var metaId = "row-edit-field-meta-" + index;
|
||||
|
|
@ -1326,14 +1289,15 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
|
||||
var context = columnFormControlContext(
|
||||
column,
|
||||
value,
|
||||
isPk,
|
||||
columnType,
|
||||
options,
|
||||
);
|
||||
var pluginControl = makeColumnField(options.manager, context);
|
||||
var preferredControl = pluginControl && pluginControl.inputType;
|
||||
var control = (preferredControl === "textarea" || shouldUseTextarea(value, columnType))
|
||||
var useTextarea =
|
||||
(pluginControl && pluginControl.useTextarea === true) ||
|
||||
shouldUseTextarea(value, columnType);
|
||||
var control = useTextarea
|
||||
? document.createElement("textarea")
|
||||
: document.createElement("input");
|
||||
control.className = "row-edit-input";
|
||||
|
|
@ -1346,10 +1310,10 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
options.valueKind || rowEditValueKind(value);
|
||||
control.dataset.primaryKey = isPk ? "1" : "0";
|
||||
control.dataset.currentValueKind = control.dataset.initialValueKind;
|
||||
if (hasSqliteDefault) {
|
||||
control.dataset.useSqliteDefault = useSqliteDefaultInitially ? "1" : "0";
|
||||
if (hasDefaultExpression) {
|
||||
control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0";
|
||||
}
|
||||
if (useSqliteDefaultInitially) {
|
||||
if (useSqliteDefault) {
|
||||
control.disabled = true;
|
||||
}
|
||||
if (options.omitIfBlank) {
|
||||
|
|
@ -1380,8 +1344,8 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
if (options.notnull) {
|
||||
metaParts.push("Required");
|
||||
}
|
||||
if (hasSqliteDefault && !useSqliteDefaultInitially) {
|
||||
metaParts.push("SQLite default: " + options.sqliteDefaultExpression);
|
||||
if (hasDefaultExpression && !useSqliteDefault) {
|
||||
metaParts.push("SQLite default: " + defaultExpression);
|
||||
}
|
||||
if (value === null) {
|
||||
metaParts.push("Current value: NULL");
|
||||
|
|
@ -1427,6 +1391,7 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
dialog: options.dialog || null,
|
||||
context: context,
|
||||
});
|
||||
field._datasetteColumnFormField = fieldApi;
|
||||
var pluginControlElement = renderColumnField(pluginControl, fieldApi);
|
||||
var controlElement =
|
||||
pluginControlElement || rowEditControlElement(control, options.autocompleteUrl);
|
||||
|
|
@ -1447,7 +1412,7 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta);
|
||||
}
|
||||
|
||||
if (hasSqliteDefault) {
|
||||
if (hasDefaultExpression) {
|
||||
var defaultBlock = document.createElement("div");
|
||||
defaultBlock.className = "row-edit-default";
|
||||
defaultBlock.setAttribute("aria-describedby", metaId);
|
||||
|
|
@ -1457,7 +1422,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.sqliteDefaultExpression;
|
||||
defaultCode.textContent = defaultExpression;
|
||||
defaultText.appendChild(defaultCode);
|
||||
|
||||
var setValueButton = document.createElement("button");
|
||||
|
|
@ -1551,9 +1516,7 @@ function valueFromRowEditControl(control) {
|
|||
return valueFromRowEditText(
|
||||
control.name,
|
||||
value,
|
||||
control.dataset.currentValueKind ||
|
||||
control.dataset.initialValueKind ||
|
||||
"string",
|
||||
rowEditControlValueKind(control),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1593,6 +1556,56 @@ function initialValueFromRowEditControl(control) {
|
|||
);
|
||||
}
|
||||
|
||||
function rowEditControlValueKind(control) {
|
||||
return (
|
||||
control.dataset.currentValueKind ||
|
||||
control.dataset.initialValueKind ||
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
function rowEditControlCleanValue(control) {
|
||||
if (Object.prototype.hasOwnProperty.call(control.dataset, "cleanValue")) {
|
||||
return control.dataset.cleanValue;
|
||||
}
|
||||
return control.dataset.initialValue || "";
|
||||
}
|
||||
|
||||
function rowEditControlCleanValueKind(control) {
|
||||
return (
|
||||
control.dataset.cleanValueKind ||
|
||||
control.dataset.initialValueKind ||
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
function rowEditControlCleanUsesSqliteDefault(control) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
control.dataset,
|
||||
"cleanUseSqliteDefault",
|
||||
)
|
||||
) {
|
||||
return control.dataset.cleanUseSqliteDefault === "1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function markRowEditControlClean(control) {
|
||||
control.dataset.cleanValue = control.value;
|
||||
control.dataset.cleanValueKind = rowEditControlValueKind(control);
|
||||
control.dataset.cleanUseSqliteDefault =
|
||||
control.dataset.useSqliteDefault === "1" ? "1" : "0";
|
||||
}
|
||||
|
||||
function cleanValueFromRowEditControl(control) {
|
||||
return valueFromRowEditText(
|
||||
control.name,
|
||||
rowEditControlCleanValue(control),
|
||||
rowEditControlCleanValueKind(control),
|
||||
);
|
||||
}
|
||||
|
||||
function rowEditValuesMatch(left, right) {
|
||||
if (left === right) {
|
||||
return true;
|
||||
|
|
@ -1608,6 +1621,28 @@ function rowEditValuesMatch(left, right) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function rowEditControlHasChanged(control) {
|
||||
var usingSqliteDefault = control.dataset.useSqliteDefault === "1";
|
||||
var cleanUsesSqliteDefault = rowEditControlCleanUsesSqliteDefault(control);
|
||||
if (usingSqliteDefault || cleanUsesSqliteDefault) {
|
||||
return usingSqliteDefault !== cleanUsesSqliteDefault;
|
||||
}
|
||||
if (
|
||||
control.value === rowEditControlCleanValue(control) &&
|
||||
rowEditControlValueKind(control) === rowEditControlCleanValueKind(control)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return !rowEditValuesMatch(
|
||||
valueFromRowEditControl(control),
|
||||
cleanValueFromRowEditControl(control),
|
||||
);
|
||||
} catch (_error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function collectRowFormValues(state) {
|
||||
var values = {};
|
||||
state.fields.querySelectorAll(".row-edit-input").forEach(function (control) {
|
||||
|
|
@ -1648,6 +1683,43 @@ function collectRowFormValues(state) {
|
|||
return values;
|
||||
}
|
||||
|
||||
function rowEditDialogHasChanges(state) {
|
||||
if (!state || !state.hasLoaded || state.isLoading) {
|
||||
return false;
|
||||
}
|
||||
var fields = state.fields.querySelectorAll(".row-edit-field");
|
||||
for (var i = 0; i < fields.length; i += 1) {
|
||||
var fieldApi = fields[i]._datasetteColumnFormField;
|
||||
if (fieldApi && fieldApi.hasChanged && fieldApi.hasChanged()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function confirmDiscardRowEditChanges(state) {
|
||||
if (!rowEditDialogHasChanges(state)) {
|
||||
return true;
|
||||
}
|
||||
var message =
|
||||
state.mode === "insert"
|
||||
? "Discard this new row?"
|
||||
: "Discard unsaved changes to this row?";
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
function closeRowEditDialogIfConfirmed(state) {
|
||||
if (!state || state.isSaving) {
|
||||
return false;
|
||||
}
|
||||
if (!confirmDiscardRowEditChanges(state)) {
|
||||
return false;
|
||||
}
|
||||
state.shouldRestoreFocus = true;
|
||||
state.dialog.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function findDataRowElement(root, rowId) {
|
||||
var elements = root.querySelectorAll("[data-row]");
|
||||
for (var i = 0; i < elements.length; i += 1) {
|
||||
|
|
@ -1905,14 +1977,13 @@ function renderRowInsertFields(state, data) {
|
|||
autocompleteUrl: foreignKeyAutocompleteUrl(column.name),
|
||||
dialog: state.dialog,
|
||||
form: state.form,
|
||||
hasSqliteDefault: column.has_default,
|
||||
defaultExpression: column.default,
|
||||
manager: state.manager,
|
||||
mode: state.mode,
|
||||
notnull: column.notnull,
|
||||
primaryKeyReadonly: false,
|
||||
sqliteDefaultExpression: column.default,
|
||||
sqliteType: column.sqlite_type,
|
||||
useSqliteDefaultInitially: column.has_default,
|
||||
useSqliteDefault: column.default !== null,
|
||||
valueKind: column.value_kind,
|
||||
},
|
||||
),
|
||||
|
|
@ -2027,9 +2098,8 @@ function ensureRowEditDialog(manager) {
|
|||
});
|
||||
|
||||
dialog.addEventListener("click", function (ev) {
|
||||
if (ev.target === dialog && !rowEditDialogState.isSaving) {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
if (ev.target === dialog) {
|
||||
closeRowEditDialogIfConfirmed(rowEditDialogState);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -2037,17 +2107,15 @@ function ensureRowEditDialog(manager) {
|
|||
if (ev.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
if (rowEditDialogState.isSaving) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
closeRowEditDialogIfConfirmed(rowEditDialogState);
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function (ev) {
|
||||
if (rowEditDialogState.isSaving) {
|
||||
if (
|
||||
rowEditDialogState.isSaving ||
|
||||
!confirmDiscardRowEditChanges(rowEditDialogState)
|
||||
) {
|
||||
ev.preventDefault();
|
||||
} else {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
|
|
|
|||
|
|
@ -200,11 +200,11 @@ This method, if present, can provide a custom form field for a column in Dataset
|
|||
|
||||
It is designed for plugins that :ref:`register custom column types <plugin_register_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.
|
||||
Datasette calls ``makeColumnField(context)`` on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the ``context`` object and only return a control object if they can handle that field. Otherwise, use a bare ``return;``.
|
||||
|
||||
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.
|
||||
|
||||
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)``.
|
||||
The row dialog tracks the value that will be sent 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
|
||||
^^^^^^^^^^^^^^
|
||||
|
|
@ -237,25 +237,17 @@ Context object
|
|||
``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 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.
|
||||
The SQLite affinity for this column, if known. This is one of ``"TEXT"``, ``"INTEGER"``, ``"REAL"``, ``"BLOB"``, ``"NUMERIC"`` or ``null`` if Datasette could not determine the affinity.
|
||||
|
||||
``notNull`` - boolean
|
||||
True if the column is defined as ``NOT NULL``.
|
||||
|
||||
``isPrimaryKey`` - boolean
|
||||
``isPk`` - boolean
|
||||
True if this column is part of the table's primary key.
|
||||
|
||||
``hasSqliteDefault`` - boolean
|
||||
True if the column has a SQLite default value and the insert form can offer the "use default" behavior.
|
||||
|
||||
``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.
|
||||
``defaultExpression`` - string or null
|
||||
The SQLite default expression for the column, if available. This is ``null`` if the column has no SQLite default. For example, a column defined with ``DEFAULT (datetime('now'))`` will have ``"datetime('now')"`` here. This is the expression from the table schema, not the actual value SQLite will insert.
|
||||
|
||||
``form`` - ``HTMLFormElement`` or null
|
||||
The row insert/edit form element.
|
||||
|
|
@ -268,13 +260,13 @@ Returned control object
|
|||
|
||||
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
|
||||
|
||||
``inputType`` - string, optional
|
||||
If set to ``"textarea"``, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. Any other value is ignored.
|
||||
``useTextarea`` - boolean, optional
|
||||
If true, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. If omitted, Datasette chooses either an ``<input>`` or ``<textarea>`` based on the column type and current value.
|
||||
|
||||
``render(field)`` - function
|
||||
Called once to render the custom field UI. ``field`` is a helper object described below.
|
||||
|
||||
The plugin should append its UI to ``field.root``. If ``render()`` returns a DOM node, Datasette appends that returned node to ``field.root``.
|
||||
The recommended pattern is to return a DOM node from ``render()``. Datasette appends that node to ``field.root``, a ``<div>`` inside the control area for that field in the row insert/edit dialog. A plugin can alternatively manipulate ``field.root`` directly and return nothing.
|
||||
|
||||
``focus(field)`` - function, optional
|
||||
Called when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
|
||||
|
|
@ -282,8 +274,6 @@ A plugin that wants to handle a field should return an object. Datasette current
|
|||
``destroy(field)`` - function, optional
|
||||
Called when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
|
||||
|
||||
Datasette adds a ``pluginName`` property to the control object internally, based on the name passed to ``registerPlugin()``.
|
||||
|
||||
The field helper object
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
@ -293,7 +283,7 @@ The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy
|
|||
The original context object passed to ``makeColumnField()``.
|
||||
|
||||
``id`` - string
|
||||
The ID Datasette assigned to the underlying form control.
|
||||
The ID Datasette assigned to ``field.input``, the backing ``<input>`` or ``<textarea>`` element.
|
||||
|
||||
``labelId`` - string
|
||||
The ID of the visible field label.
|
||||
|
|
@ -302,7 +292,7 @@ The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy
|
|||
The ID of the field metadata/help text. This metadata can include details such as ``Primary key``, ``Required``, ``Current value: NULL`` or ``Custom type: file``.
|
||||
|
||||
``root`` - ``HTMLElement``
|
||||
The empty container element created by Datasette for this custom field. Plugins should append their UI to this element.
|
||||
The empty ``<div>`` container created by Datasette for this custom field. It is inside the control area for the field in the row insert/edit dialog, next to the field label and above the field metadata. Datasette appends the DOM node returned by ``render(field)`` to this element. Plugins can alternatively manipulate this element directly and return nothing from ``render(field)``.
|
||||
|
||||
``input`` - ``HTMLInputElement`` or ``HTMLTextAreaElement``
|
||||
The core-owned backing form control. Plugins can keep this visible, wrap it or hide it, but should use the value helper methods below rather than mutating ``input.value`` directly.
|
||||
|
|
@ -320,12 +310,12 @@ The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy
|
|||
The containing modal dialog.
|
||||
|
||||
``getValue()`` - function
|
||||
Returns the current value Datasette will submit for this field.
|
||||
Returns the current value for this field.
|
||||
|
||||
Datasette uses string values by default. Insert fields for ``"INTEGER"`` and ``"REAL"`` SQLite columns return numbers, or ``null`` if left blank. Plugins can use strings, numbers, booleans or ``null``. If a plugin is editing structured data stored in a SQLite ``TEXT`` column, such as JSON, it should serialize that data to a string before calling ``setValue()``.
|
||||
|
||||
``setValue(value, options)`` - function
|
||||
Sets the current value Datasette will submit for this field. ``value`` should be a string, number, boolean or ``null``. This also dispatches ``input`` and ``change`` events from the backing input. Pass ``{dispatch: false}`` as the second argument to skip those events.
|
||||
``setValue(value)`` - function
|
||||
Sets the current value for this field. ``value`` should be a string, number, boolean or ``null``.
|
||||
|
||||
Calling ``setValue()`` also stops using the SQLite default for the field, if it was previously selected.
|
||||
|
||||
|
|
@ -333,26 +323,19 @@ The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy
|
|||
Returns the submitted-value representation the field had when the form was rendered. For edit forms this is the raw row value from the database. For insert forms this is the blank starting value.
|
||||
|
||||
``hasChanged()`` - function
|
||||
Returns true if the field value differs from its initial value, or if the field's SQLite-default state has changed.
|
||||
Returns true if the field has changed since Datasette last considered it unmodified. By default that means the field state when the insert/edit form was rendered.
|
||||
|
||||
``clearValue(options)`` - function
|
||||
Sets the value to ``null``. Accepts the same options as ``setValue()``.
|
||||
``clearValue()`` - function
|
||||
Sets the value to ``null``.
|
||||
|
||||
``resetValue(options)`` - function
|
||||
Restores the initial field value. Accepts the same options as ``setValue()``.
|
||||
``markClean()`` - function
|
||||
Tells Datasette to treat the field's current state as unmodified. After calling this method, ``hasChanged()`` returns false until the field value changes again or its SQLite-default state changes.
|
||||
|
||||
This is useful when a plugin rewrites the starting value into an equivalent representation while initializing its editor. For example, a rich text editor might normalize empty HTML or reserialize its initial document before the user has made any edits.
|
||||
|
||||
``isUsingSqliteDefault()`` - function
|
||||
Returns true if the insert dialog is currently set to omit this column and use the SQLite default.
|
||||
|
||||
``useSqliteDefault(options)`` - function
|
||||
Switches the field to use the SQLite default, if one exists. Accepts ``{dispatch: false}``.
|
||||
|
||||
``stopUsingSqliteDefault(options)`` - function
|
||||
Switches the field away from the SQLite default without changing the current field value. Accepts ``{dispatch: false}``.
|
||||
|
||||
``dispatchChange()`` - function
|
||||
Dispatches ``input`` and ``change`` events from the backing input.
|
||||
|
||||
``setValidity(message)`` - function
|
||||
Sets a custom validation message for this field, marks the backing input with ``aria-invalid="true"`` and shows the message in the field metadata area. Pass an empty string to clear the error.
|
||||
|
||||
|
|
@ -362,16 +345,16 @@ The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy
|
|||
Submitted value contract
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The field value contract is deliberately narrow. Datasette submits field values to the row insert/update JSON API, so a custom field value should be one of:
|
||||
The ``field.setValue()`` method accepts the following value types:
|
||||
|
||||
* string
|
||||
* number
|
||||
* boolean
|
||||
* ``null``
|
||||
|
||||
Plugins should not pass objects or arrays to ``field.setValue()``. If a column stores structured data in SQLite, such as JSON in a ``TEXT`` column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
|
||||
These values are used as column values in requests to the :ref:`insert rows <TableInsertView>` and :ref:`update row <RowUpdateView>` JSON APIs.
|
||||
|
||||
``field.input.dataset`` is reserved for Datasette's private form state. Plugins should not read from it, write to it, or use it to change how Datasette serializes values.
|
||||
Plugins should not pass objects or arrays to ``field.setValue()``. If a column stores structured data in SQLite, such as JSON in a ``TEXT`` column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
|
||||
|
||||
Value helpers
|
||||
^^^^^^^^^^^^^
|
||||
|
|
@ -386,18 +369,16 @@ Custom fields should use ``field.getValue()`` and ``field.setValue(value)`` for
|
|||
|
||||
Plugins can keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
|
||||
|
||||
``field.setValue()`` updates the backing input and Datasette's private value serialization state.
|
||||
``field.setValue()`` updates both ``field.input`` and the value used in the insert/update request.
|
||||
|
||||
For insert forms with a SQLite default, ``field.isUsingSqliteDefault()`` indicates whether Datasette will omit that column from the insert payload. Calling ``field.setValue(value)`` automatically stops using the SQLite default. A plugin can also expose explicit controls that call ``field.useSqliteDefault()`` and ``field.stopUsingSqliteDefault()``.
|
||||
|
||||
Datasette's built-in ``json`` column type is implemented using this same JavaScript plugin hook. Datasette registers a small textarea-backed control for fields where ``context.columnType.type === "json"``; that control validates the field as JSON while the value changes and marks it visibly invalid if parsing fails. The submitted value remains the textarea string. The generic field API does not special-case custom column types.
|
||||
|
||||
For example, a file picker can store a file ID string or ``null`` without modifying the backing input directly:
|
||||
For example, a file picker that stores a selected file ID can hide the backing input and call ``field.setValue()`` when the selection changes:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
field.input.type = "hidden";
|
||||
field.setValue(fileId || null);
|
||||
field.setValue(fileId);
|
||||
|
||||
For insert forms with a SQLite default, ``field.isUsingSqliteDefault()`` indicates whether Datasette will omit that column from the insert payload. Calling ``field.setValue(value)`` automatically stops using the SQLite default.
|
||||
|
||||
Lazy loading large controls
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
@ -414,15 +395,15 @@ The JavaScript file that registers ``makeColumnField()`` should be small. If the
|
|||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "my-editor") {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
return {
|
||||
inputType: "textarea",
|
||||
useTextarea: true,
|
||||
render(field) {
|
||||
field.root.appendChild(field.input);
|
||||
import(editorUrl).then(function () {
|
||||
// Enhance field.input here.
|
||||
});
|
||||
return field.input;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -432,7 +413,7 @@ The JavaScript file that registers ``makeColumnField()`` should be small. If the
|
|||
Example: textarea-backed custom element
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom element:
|
||||
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom ``<my-markdown-editor>`` Web Component element:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
|
|
@ -442,16 +423,15 @@ This example handles a ``markdown-editor`` column type by asking Datasette for a
|
|||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "markdown-editor") {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
inputType: "textarea",
|
||||
useTextarea: true,
|
||||
|
||||
render(field) {
|
||||
const editor = document.createElement("my-markdown-editor");
|
||||
editor.appendChild(field.input);
|
||||
field.root.appendChild(editor);
|
||||
|
||||
if (field.labelId) {
|
||||
field.input.setAttribute("aria-labelledby", field.labelId);
|
||||
|
|
@ -459,6 +439,8 @@ This example handles a ``markdown-editor`` column type by asking Datasette for a
|
|||
if (field.descriptionId) {
|
||||
field.input.setAttribute("aria-describedby", field.descriptionId);
|
||||
}
|
||||
|
||||
return editor;
|
||||
},
|
||||
|
||||
focus(field) {
|
||||
|
|
@ -474,63 +456,6 @@ This example handles a ``markdown-editor`` column type by asking Datasette for a
|
|||
});
|
||||
});
|
||||
|
||||
Example: hidden input with custom picker
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This example handles an ``asset`` column type by hiding the core input and writing an asset ID into it when the user selects an item:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("datasette_init", function (event) {
|
||||
event.detail.registerPlugin("asset-picker", {
|
||||
version: "0.1",
|
||||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "asset") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
render(field) {
|
||||
field.input.type = "hidden";
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-labelledby", field.labelId);
|
||||
group.setAttribute("aria-describedby", field.descriptionId);
|
||||
|
||||
const current = document.createElement("span");
|
||||
current.textContent = field.getValue() || "No asset selected";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Choose asset";
|
||||
button.addEventListener("click", async function () {
|
||||
const assetId = await chooseAsset();
|
||||
if (assetId === null) {
|
||||
return;
|
||||
}
|
||||
field.setValue(assetId || null);
|
||||
current.textContent = assetId || "No asset selected";
|
||||
});
|
||||
|
||||
group.appendChild(current);
|
||||
group.appendChild(button);
|
||||
field.root.appendChild(field.input);
|
||||
field.root.appendChild(group);
|
||||
},
|
||||
|
||||
focus(field) {
|
||||
const button = field.root.querySelector("button");
|
||||
if (button) {
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Accessibility
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
|||
|
|
@ -518,13 +518,18 @@ async def test_column_type_class_attributes(ds_ct):
|
|||
|
||||
|
||||
def test_sqlite_type_from_declared_type():
|
||||
assert SQLiteType.from_declared_type(None) == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("text") == SQLiteType.TEXT
|
||||
assert SQLiteType.from_declared_type("varchar(255)") == SQLiteType.TEXT
|
||||
assert SQLiteType.from_declared_type("integer") == SQLiteType.INTEGER
|
||||
assert SQLiteType.from_declared_type("float") == SQLiteType.REAL
|
||||
assert SQLiteType.from_declared_type("blob") == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("") == SQLiteType.NULL
|
||||
assert SQLiteType.from_declared_type("numeric") is None
|
||||
assert SQLiteType.from_declared_type("") == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("numeric") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("decimal(10,5)") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("boolean") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("date") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("null") == SQLiteType.NUMERIC
|
||||
|
||||
|
||||
# --- JSON API ---
|
||||
|
|
|
|||
|
|
@ -42,15 +42,15 @@ def test_datasette_manager_make_column_field():
|
|||
|
||||
window.__DATASETTE__.registerPlugin("declines", {
|
||||
makeColumnField() {
|
||||
return null;
|
||||
return;
|
||||
},
|
||||
});
|
||||
window.__DATASETTE__.registerPlugin("handles", {
|
||||
makeColumnField(context) {
|
||||
if (context.columnType.type !== "demo") {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
return { inputType: "textarea" };
|
||||
return { useTextarea: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ def test_datasette_manager_make_column_field():
|
|||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pluginName": "handles",
|
||||
"inputType": "textarea",
|
||||
"useTextarea": True,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -186,33 +186,39 @@ def test_table_plugin_column_field_api():
|
|||
|
||||
const context = columnFormControlContext(
|
||||
"logo",
|
||||
"df-old",
|
||||
false,
|
||||
true,
|
||||
{ type: "file", config: null },
|
||||
{
|
||||
mode: "edit",
|
||||
hasSqliteDefault: true,
|
||||
sqliteDefaultExpression: "lower(hex(randomblob(4)))",
|
||||
useSqliteDefaultInitially: true,
|
||||
defaultExpression: "lower(hex(randomblob(4)))",
|
||||
useSqliteDefault: true,
|
||||
}
|
||||
);
|
||||
if ("defaultValue" in context) {
|
||||
throw new Error("context should not expose defaultValue");
|
||||
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.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 (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 ("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");
|
||||
if (!context.isPk) {
|
||||
throw new Error("context.isPk should say whether the column is a primary key");
|
||||
}
|
||||
|
||||
const control = new FakeElement("input");
|
||||
|
|
@ -241,7 +247,7 @@ def test_table_plugin_column_field_api():
|
|||
render(field) {
|
||||
renderArgumentCount = arguments.length;
|
||||
renderField = field;
|
||||
field.root.appendChild(document.createElement("button"));
|
||||
return document.createElement("button");
|
||||
},
|
||||
},
|
||||
field
|
||||
|
|
@ -252,6 +258,9 @@ def test_table_plugin_column_field_api():
|
|||
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) {
|
||||
|
|
@ -274,9 +283,145 @@ def test_table_plugin_column_field_api():
|
|||
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}`);
|
||||
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");
|
||||
|
|
@ -420,13 +565,12 @@ def test_builtin_json_column_field_validation():
|
|||
column: "metadata",
|
||||
columnType: { type: "json", config: {} },
|
||||
});
|
||||
if (!pluginControl || pluginControl.inputType !== "textarea") {
|
||||
if (!pluginControl || pluginControl.useTextarea !== true) {
|
||||
throw new Error("JSON column plugin should request a textarea");
|
||||
}
|
||||
|
||||
const context = columnFormControlContext(
|
||||
"metadata",
|
||||
'{"ok": true}',
|
||||
false,
|
||||
{ type: "json", config: {} },
|
||||
{ mode: "edit" }
|
||||
|
|
|
|||
|
|
@ -962,8 +962,10 @@ async def test_table_insert_action_button_and_data():
|
|||
id integer primary key,
|
||||
name text not null,
|
||||
score integer default 5,
|
||||
price numeric,
|
||||
created text default (datetime('now')),
|
||||
body text
|
||||
body text,
|
||||
typeless
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
|
|
@ -985,10 +987,12 @@ async def test_table_insert_action_button_and_data():
|
|||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"name",
|
||||
"score",
|
||||
"price",
|
||||
"created",
|
||||
"body",
|
||||
"typeless",
|
||||
]
|
||||
name, score, created, body = insert_data["columns"]
|
||||
name, score, price, created, body, typeless = insert_data["columns"]
|
||||
assert name["notnull"] == 1
|
||||
assert name["sqlite_type"] == "TEXT"
|
||||
assert name["value_kind"] == "string"
|
||||
|
|
@ -997,12 +1001,16 @@ async def test_table_insert_action_button_and_data():
|
|||
assert score["has_default"]
|
||||
assert score["sqlite_type"] == "INTEGER"
|
||||
assert score["value_kind"] == "number"
|
||||
assert price["sqlite_type"] == "NUMERIC"
|
||||
assert price["value_kind"] == "string"
|
||||
assert created["default"] == "datetime('now')"
|
||||
assert created["has_default"]
|
||||
assert created["sqlite_type"] == "TEXT"
|
||||
assert body["sqlite_type"] == "TEXT"
|
||||
assert body["value_kind"] == "string"
|
||||
assert body["column_type"] == {"type": "textarea", "config": None}
|
||||
assert typeless["sqlite_type"] == "BLOB"
|
||||
assert typeless["value_kind"] == "string"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue