Support BLOB values in row edit UI

This commit is contained in:
Simon Willison 2026-07-03 16:09:27 -07:00
commit 19dde1c860
7 changed files with 754 additions and 26 deletions

View file

@ -1700,6 +1700,118 @@ textarea.row-edit-input {
background: var(--paper);
}
.row-edit-binary-control {
display: grid;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--rule);
border-radius: 5px;
padding: 10px;
background: #fff;
}
.row-edit-binary-control:focus {
border-color: var(--accent);
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.row-edit-binary-preview[hidden] {
display: none;
}
.row-edit-binary-preview img {
display: block;
max-width: min(240px, 100%);
max-height: 180px;
border: 1px solid var(--rule);
border-radius: 4px;
background: var(--paper);
}
.row-edit-binary-status {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.row-edit-binary-size {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.86rem;
}
.row-edit-binary-name {
color: var(--muted);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.row-edit-binary-name[hidden] {
display: none;
}
.row-edit-binary-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.row-edit-binary-file-button,
.row-edit-binary-clear {
appearance: none;
border: 1px solid var(--rule);
border-radius: 4px;
background: #fff;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.78rem;
line-height: 1.2;
padding: 6px 8px;
}
.row-edit-binary-file-button:hover,
.row-edit-binary-file-button:focus-within,
.row-edit-binary-clear:hover,
.row-edit-binary-clear:focus {
background: #f8fafc;
}
.row-edit-binary-file-button:focus-within,
.row-edit-binary-clear:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 1px;
}
.row-edit-binary-file-button input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.row-edit-binary-clear[hidden] {
display: none;
}
.row-edit-binary-drop-target {
border: 1px dashed var(--rule);
border-radius: 4px;
padding: 7px 8px;
color: var(--muted);
font-size: 0.78rem;
}
.row-edit-binary-dragover .row-edit-binary-drop-target {
border-color: var(--accent);
background: var(--paper);
color: var(--ink);
}
.row-edit-default {
display: grid;
grid-template-columns: minmax(0, 1fr) 7.25rem;

View file

@ -2,6 +2,7 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog";
var rowDeleteDialogState = null;
var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
var rowEditDialogState = null;
var ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
var TABLE_CREATE_DIALOG_ID = "table-create-dialog";
var tableCreateDialogState = null;
var TABLE_ALTER_DIALOG_ID = "table-alter-dialog";
@ -3598,7 +3599,7 @@ function rowJsonUrl(row) {
return "";
}
url.pathname = url.pathname + ".json";
url.searchParams.set("_extra", "columns,column_types");
url.searchParams.set("_extra", "columns,column_types,column_details");
return url.toString();
}
@ -3878,10 +3879,155 @@ function initRowDeleteActions(manager) {
});
}
function isBase64JsonValue(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
var keys = Object.keys(value);
return (
keys.length === 2 &&
Object.prototype.hasOwnProperty.call(value, "$base64") &&
Object.prototype.hasOwnProperty.call(value, "encoded") &&
value.$base64 === true &&
typeof value.encoded === "string"
);
}
function shouldUseBinaryControl(value, options) {
options = options || {};
var sqliteType = (options.sqliteType || "").toLowerCase();
return (
isBase64JsonValue(value) ||
sqliteType === "blob" ||
options.valueKind === "binary"
);
}
function binaryEncodedValue(value) {
return isBase64JsonValue(value) ? value.encoded : "";
}
function binaryByteLengthFromBase64(encoded) {
encoded = (encoded || "").replace(/\s/g, "");
if (!encoded) {
return 0;
}
var padding = 0;
if (encoded.slice(-2) === "==") {
padding = 2;
} else if (encoded.slice(-1) === "=") {
padding = 1;
}
return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding);
}
function rowEditBinaryValue(encoded) {
return {
$base64: true,
encoded: encoded || "",
};
}
function formatRowEditBinarySize(byteLength) {
var number = byteLength.toLocaleString();
return "Binary: " + number + " byte" + (byteLength === 1 ? "" : "s");
}
function base64ToUint8Array(encoded) {
var binary = window.atob(encoded || "");
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function uint8ArrayToBase64(bytes) {
var chunks = [];
var chunkSize = 0x8000;
for (var i = 0; i < bytes.length; i += chunkSize) {
chunks.push(
String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)),
);
}
return window.btoa(chunks.join(""));
}
function rowEditBinaryImageMimeType(bytes) {
if (!bytes || !bytes.length) {
return null;
}
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return "image/png";
}
if (
bytes.length >= 3 &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff
) {
return "image/jpeg";
}
if (
bytes.length >= 6 &&
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x38 &&
(bytes[4] === 0x37 || bytes[4] === 0x39) &&
bytes[5] === 0x61
) {
return "image/gif";
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return "image/webp";
}
if (
bytes.length >= 12 &&
bytes[4] === 0x66 &&
bytes[5] === 0x74 &&
bytes[6] === 0x79 &&
bytes[7] === 0x70 &&
bytes[8] === 0x61 &&
bytes[9] === 0x76 &&
bytes[10] === 0x69 &&
(bytes[11] === 0x66 || bytes[11] === 0x73)
) {
return "image/avif";
}
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
return "image/bmp";
}
return null;
}
function valueToEditText(value) {
if (value === null || typeof value === "undefined") {
return "";
}
if (isBase64JsonValue(value)) {
return value.encoded;
}
if (typeof value === "object") {
return JSON.stringify(value, null, 2);
}
@ -3892,6 +4038,9 @@ function shouldUseTextarea(value, columnType) {
if (columnType && columnType.type === "textarea") {
return true;
}
if (isBase64JsonValue(value)) {
return false;
}
if (value && typeof value === "object") {
return true;
}
@ -3900,6 +4049,9 @@ function shouldUseTextarea(value, columnType) {
}
function rowEditValueKind(value) {
if (isBase64JsonValue(value)) {
return "binary";
}
if (value === null || typeof value === "undefined") {
return "null";
}
@ -3923,6 +4075,263 @@ function rowEditControlElement(control, autocompleteUrl) {
return autocomplete;
}
function revokeRowEditBinaryPreview(wrapper) {
if (wrapper && wrapper._rowEditBinaryPreviewUrl) {
URL.revokeObjectURL(wrapper._rowEditBinaryPreviewUrl);
wrapper._rowEditBinaryPreviewUrl = null;
}
}
function updateRowEditBinaryPreview(wrapper, encoded, byteLength) {
var preview = wrapper.querySelector(".row-edit-binary-preview");
if (!preview) {
return;
}
revokeRowEditBinaryPreview(wrapper);
preview.hidden = true;
preview.textContent = "";
if (
!encoded ||
byteLength >= ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES ||
!window.atob ||
!window.Blob ||
!window.URL ||
!URL.createObjectURL
) {
return;
}
var bytes;
try {
bytes = base64ToUint8Array(encoded);
} catch (_error) {
return;
}
var mimeType = rowEditBinaryImageMimeType(bytes);
if (!mimeType) {
return;
}
var image = document.createElement("img");
image.alt = "";
var objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }));
wrapper._rowEditBinaryPreviewUrl = objectUrl;
image.src = objectUrl;
preview.appendChild(image);
var showPreview = function () {
if (wrapper._rowEditBinaryPreviewUrl === objectUrl) {
preview.hidden = false;
}
};
var hidePreview = function () {
if (wrapper._rowEditBinaryPreviewUrl === objectUrl) {
revokeRowEditBinaryPreview(wrapper);
preview.hidden = true;
preview.textContent = "";
}
};
if (image.decode) {
image.decode().then(showPreview).catch(hidePreview);
} else {
image.onload = showPreview;
image.onerror = hidePreview;
}
}
function updateRowEditBinaryDisplay(wrapper, control, fileName) {
var kind = rowEditControlValueKind(control);
var size = wrapper.querySelector(".row-edit-binary-size");
var name = wrapper.querySelector(".row-edit-binary-name");
var clear = wrapper.querySelector(".row-edit-binary-clear");
var byteLength =
kind === "binary" ? binaryByteLengthFromBase64(control.value) : null;
if (size) {
size.textContent =
kind === "binary" ? formatRowEditBinarySize(byteLength) : "No binary data";
}
if (name) {
name.textContent = fileName || "";
name.hidden = !fileName;
}
if (clear) {
clear.hidden = control.dataset.notNull === "1" || kind !== "binary";
}
if (kind === "binary") {
updateRowEditBinaryPreview(wrapper, control.value, byteLength);
} else {
updateRowEditBinaryPreview(wrapper, "", 0);
}
}
function setRowEditBinaryControlValue(control, encoded, fileName) {
control.value = encoded || "";
control.dataset.currentValueKind = "binary";
updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, fileName);
control.dispatchEvent(new Event("input", { bubbles: true }));
}
function clearRowEditBinaryControlValue(control) {
control.value = "";
control.dataset.currentValueKind = "null";
updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, "");
control.dispatchEvent(new Event("input", { bubbles: true }));
}
function readRowEditBinaryFile(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
try {
var bytes = new Uint8Array(reader.result || new ArrayBuffer(0));
resolve({
encoded: uint8ArrayToBase64(bytes),
name: file && file.name ? file.name : "",
});
} catch (error) {
reject(error);
}
};
reader.onerror = function () {
reject(reader.error || new Error("Could not read file"));
};
reader.readAsArrayBuffer(file);
});
}
function rowEditBinaryValueFromText(text) {
var encoder = new TextEncoder();
var bytes = encoder.encode(text || "");
return {
encoded: uint8ArrayToBase64(bytes),
name: "Pasted text",
};
}
function rowEditBinaryFirstClipboardFile(clipboardData) {
if (!clipboardData) {
return null;
}
if (clipboardData.files && clipboardData.files.length) {
return clipboardData.files[0];
}
var items = clipboardData.items || [];
for (var i = 0; i < items.length; i += 1) {
if (items[i].kind === "file") {
return items[i].getAsFile();
}
}
return null;
}
function handleRowEditBinaryFile(control, file) {
if (!file) {
return;
}
readRowEditBinaryFile(file)
.then(function (value) {
setRowEditBinaryControlValue(control, value.encoded, value.name);
})
.catch(function (error) {
console.error("Could not read binary file", error);
});
}
function createRowEditBinaryControlElement(control, value, options, labelId) {
var wrapper = document.createElement("div");
wrapper.className = "row-edit-binary-control";
wrapper.dataset.column = control.name;
wrapper.setAttribute("role", "group");
wrapper.setAttribute("tabindex", "0");
wrapper.setAttribute("aria-labelledby", labelId);
var preview = document.createElement("div");
preview.className = "row-edit-binary-preview";
preview.hidden = true;
var status = document.createElement("div");
status.className = "row-edit-binary-status";
var size = document.createElement("span");
size.className = "row-edit-binary-size";
var name = document.createElement("span");
name.className = "row-edit-binary-name";
name.hidden = true;
status.appendChild(size);
status.appendChild(name);
var actions = document.createElement("div");
actions.className = "row-edit-binary-actions";
var fileLabel = document.createElement("label");
fileLabel.className = "row-edit-binary-file-button";
fileLabel.textContent = "Attach file";
var fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.setAttribute("aria-label", "Attach file for " + control.name);
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files.length) {
handleRowEditBinaryFile(control, fileInput.files[0]);
}
});
fileLabel.appendChild(fileInput);
actions.appendChild(fileLabel);
var clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.className = "row-edit-binary-clear";
clearButton.textContent = "Set NULL";
clearButton.addEventListener("click", function () {
clearRowEditBinaryControlValue(control);
wrapper.focus();
});
actions.appendChild(clearButton);
var dropTarget = document.createElement("div");
dropTarget.className = "row-edit-binary-drop-target";
dropTarget.textContent = "Drop or paste file contents";
["dragenter", "dragover"].forEach(function (eventName) {
wrapper.addEventListener(eventName, function (ev) {
ev.preventDefault();
wrapper.classList.add("row-edit-binary-dragover");
});
});
["dragleave", "drop"].forEach(function (eventName) {
wrapper.addEventListener(eventName, function () {
wrapper.classList.remove("row-edit-binary-dragover");
});
});
wrapper.addEventListener("drop", function (ev) {
ev.preventDefault();
var file = ev.dataTransfer && ev.dataTransfer.files[0];
handleRowEditBinaryFile(control, file);
});
wrapper.addEventListener("paste", function (ev) {
var file = rowEditBinaryFirstClipboardFile(ev.clipboardData);
if (file) {
ev.preventDefault();
handleRowEditBinaryFile(control, file);
return;
}
var text = ev.clipboardData && ev.clipboardData.getData("text");
if (text) {
ev.preventDefault();
var pasted = rowEditBinaryValueFromText(text);
setRowEditBinaryControlValue(control, pasted.encoded, pasted.name);
}
});
control.type = "hidden";
control._rowEditBinaryWrapper = wrapper;
wrapper.appendChild(control);
wrapper.appendChild(preview);
wrapper.appendChild(status);
wrapper.appendChild(actions);
wrapper.appendChild(dropTarget);
updateRowEditBinaryDisplay(wrapper, control, "");
return wrapper;
}
function columnTypeForContext(columnType) {
if (!columnType) {
return null;
@ -4186,6 +4595,11 @@ function focusFirstRowEditControl(state, options) {
if (focusRowEditPluginControl(field)) {
return true;
}
var binaryControl = field.querySelector(".row-edit-binary-control");
if (binaryControl) {
binaryControl.focus();
return true;
}
control.focus();
return true;
}
@ -4209,6 +4623,11 @@ function destroyRowEditFields(state) {
}
}
});
state.fields
.querySelectorAll(".row-edit-binary-control")
.forEach(function (binaryControl) {
revokeRowEditBinaryPreview(binaryControl);
});
state.fields.innerHTML = "";
}
@ -4235,34 +4654,50 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
controlWrap.className = "row-edit-control-wrap";
var context = columnFormControlContext(column, isPk, columnType, options);
var pluginControl = makeColumnField(options.manager, context);
var isBinaryField = shouldUseBinaryControl(value, options);
var pluginControl = isBinaryField
? null
: makeColumnField(options.manager, context);
var useTextarea =
(pluginControl && pluginControl.useTextarea === true) ||
shouldUseTextarea(value, columnType);
!isBinaryField &&
((pluginControl && pluginControl.useTextarea === true) ||
shouldUseTextarea(value, columnType));
var control = useTextarea
? document.createElement("textarea")
: document.createElement("input");
var initialValue = isBinaryField
? binaryEncodedValue(value)
: valueToEditText(value);
var initialValueKind = options.valueKind || rowEditValueKind(value);
if (isBinaryField) {
initialValueKind = isBase64JsonValue(value) ? "binary" : "null";
}
control.className = "row-edit-input";
control.id = fieldId;
control.name = column;
control.value = valueToEditText(value);
control.value = initialValue;
control.setAttribute("aria-describedby", metaId);
control.dataset.initialValue = valueToEditText(value);
control.dataset.initialValueKind =
options.valueKind || rowEditValueKind(value);
control.dataset.initialValue = initialValue;
control.dataset.initialValueKind = initialValueKind;
control.dataset.primaryKey = isPk ? "1" : "0";
control.dataset.currentValueKind = control.dataset.initialValueKind;
if (isBinaryField) {
control.dataset.binaryField = "1";
control.dataset.notNull = options.notnull ? "1" : "0";
}
if (hasDefaultExpression) {
control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0";
}
if (useSqliteDefault) {
control.disabled = true;
}
if (options.omitIfBlank) {
if (options.omitIfBlank || (isBinaryField && options.mode === "insert")) {
control.dataset.omitIfBlank = "1";
}
if (control.nodeName === "TEXTAREA") {
if (isBinaryField) {
control.type = "hidden";
} else if (control.nodeName === "TEXTAREA") {
control.rows = Math.min(8, Math.max(3, control.value.split("\n").length));
} else {
control.type = "text";
@ -4336,9 +4771,11 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
field._datasetteColumnFormField = fieldApi;
var pluginControlElement = renderColumnField(pluginControl, fieldApi);
var controlElement =
(isBinaryField &&
createRowEditBinaryControlElement(control, value, options, labelId)) ||
pluginControlElement ||
rowEditControlElement(control, options.autocompleteUrl);
if (options.autocompleteUrl && !pluginControlElement) {
if (options.autocompleteUrl && !pluginControlElement && !isBinaryField) {
control.addEventListener("input", function () {
setForeignKeyMetaLink(meta, options.autocompleteUrl, null);
});
@ -4460,6 +4897,9 @@ function setRowEditDialogSaving(state, isSaving) {
function valueFromRowEditControl(control) {
var value = control.value;
if (rowEditControlValueKind(control) === "binary") {
return rowEditBinaryValue(value);
}
return valueFromRowEditText(
control.name,
value,
@ -4470,6 +4910,9 @@ function valueFromRowEditControl(control) {
function valueFromRowEditText(name, value, initialValueKind) {
var trimmed = value.trim();
if (initialValueKind === "binary") {
return rowEditBinaryValue(value);
}
if (initialValueKind === "null" && value === "") {
return null;
}
@ -4597,7 +5040,11 @@ function collectRowFormValues(state) {
if (control.dataset.useSqliteDefault === "1") {
return;
}
if (control.dataset.omitIfBlank === "1" && control.value === "") {
if (
control.dataset.omitIfBlank === "1" &&
control.value === "" &&
rowEditControlValueKind(control) !== "binary"
) {
return;
}
if (
@ -4919,9 +5366,11 @@ function renderRowEditFields(state, data) {
var columns = data.columns || (row ? Object.keys(row) : []);
var primaryKeys = data.primary_keys || [];
var columnTypes = data.column_types || {};
var columnDetails = data.column_details || {};
destroyRowEditFields(state);
columns.forEach(function (column, index) {
var columnDetail = columnDetails[column] || {};
state.fields.appendChild(
createRowEditField(
column,
@ -4935,7 +5384,9 @@ function renderRowEditFields(state, data) {
form: state.form,
manager: state.manager,
mode: state.mode,
notnull: columnDetail.notnull,
primaryKeyReadonly: true,
sqliteType: columnDetail.sqlite_type,
},
),
);

View file

@ -237,10 +237,8 @@ class CustomJSONEncoder(json.JSONEncoder):
- ``sqlite3.Row`` becomes a tuple
- ``sqlite3.Cursor`` becomes a list
If a binary blob can be decoded as UTF-8, the encoder returns it as text.
If it can't (for example, images), it is encoded as an object, with the actual
data base64-encoded, like so: ::
Binary blobs are encoded as an object, with the actual data base64-encoded,
like so: ::
{
"$base64": True,
@ -256,14 +254,10 @@ class CustomJSONEncoder(json.JSONEncoder):
if isinstance(obj, sqlite3.Cursor):
return list(obj)
if isinstance(obj, bytes):
# Does it encode to utf8?
try:
return obj.decode("utf8")
except UnicodeDecodeError:
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return json.JSONEncoder.default(self, obj)

View file

@ -17,7 +17,7 @@ Datasette includes special handling for these binary values. The Datasette inter
Binary values in JSON
---------------------
Binary data is represented in ``.json`` exports using Base64 encoding.
Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text.
https://latest.datasette.io/fixtures/binary_data.json?_shape=array

View file

@ -9,6 +9,8 @@ Changelog
1.0a36 (in development)
-----------------------
- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format <binary_json_format>`, even when the bytes could be decoded as UTF-8 text.
- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control.
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
.. _v1_0_a35:

View file

@ -1,3 +1,4 @@
import base64
import json
import socket
import subprocess
@ -10,6 +11,10 @@ import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
PNG_1X1_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
def find_free_port():
with socket.socket() as sock:
@ -102,6 +107,11 @@ def write_playwright_database(db_path):
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
create table binary_files (
id integer primary key,
name text not null,
data blob
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
@ -111,6 +121,19 @@ def write_playwright_database(db_path):
5
);
""")
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Raw bytes", b"\x00\x01\x02\x03"),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("PNG image", PNG_1X1_BYTES),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Null bytes", None),
)
conn.commit()
finally:
conn.close()
@ -145,6 +168,14 @@ def write_playwright_config(config_path):
"alter-table": True,
},
},
"binary_files": {
"label_column": "name",
"permissions": {
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
},
},
},
@ -278,6 +309,15 @@ def project_row(datasette_server, pk):
return rows[0]
def binary_file_blob(datasette_server, pk):
response = httpx.get(
f"{datasette_server}data/binary_files/{pk}.blob",
params={"_blob_column": "data"},
)
response.raise_for_status()
return response.content
def open_jump_menu(page):
page.keyboard.press("/")
page.locator("navigation-search .search-input").wait_for()
@ -877,6 +917,131 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
assert data["notes"] == "Edited from Playwright"
@pytest.mark.playwright
def test_edit_row_binary_control_shows_size_and_image_preview(page, datasette_server):
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
raw_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
raw_control.wait_for()
assert (
raw_control.locator(".row-edit-binary-size").inner_text() == "Binary: 4 bytes"
)
assert raw_control.locator(".row-edit-binary-preview img").count() == 0
assert dialog.locator('textarea[name="data"]').count() == 0
assert dialog.locator('input[type="hidden"][name="data"]').count() == 1
dialog.locator(".row-edit-cancel").click()
page.locator('tr[data-row="2"] button[data-row-action="edit"]').click()
image_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
image_control.wait_for()
assert (
image_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(PNG_1X1_BYTES)} bytes"
)
image = image_control.locator(".row-edit-binary-preview img")
image.wait_for()
assert image.get_attribute("src").startswith("blob:")
@pytest.mark.playwright
def test_edit_row_binary_control_replaces_blob_from_file(page, datasette_server):
replacement = b"Replacement \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "replacement.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
assert (
binary_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(replacement)} bytes"
)
assert "replacement.bin" in binary_control.inner_text()
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 1").wait_for()
assert binary_file_blob(datasette_server, 1) == replacement
@pytest.mark.playwright
def test_edit_row_binary_control_handles_null_blob(page, datasette_server):
replacement = b"From NULL"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="3"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
assert (
binary_control.locator(".row-edit-binary-size").inner_text() == "No binary data"
)
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "from-null.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
assert (
binary_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(replacement)} bytes"
)
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 3").wait_for()
assert binary_file_blob(datasette_server, 3) == replacement
@pytest.mark.playwright
def test_insert_row_binary_control_accepts_pasted_file(page, datasette_server):
pasted = b"Pasted \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator('input[name="name"]').fill("Pasted bytes")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.evaluate(
"""(node, bytes) => {
const transfer = new DataTransfer();
transfer.items.add(
new File([new Uint8Array(bytes)], "pasted.bin", {
type: "application/octet-stream"
})
);
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: transfer });
node.dispatchEvent(event);
}""",
list(pasted),
)
assert (
binary_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(pasted)} bytes"
)
assert "pasted.bin" in binary_control.inner_text()
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Inserted row 4").wait_for()
assert binary_file_blob(datasette_server, 4) == pasted
@pytest.mark.playwright
def test_delete_row_flow_removes_row(page, datasette_server):
page.goto(f"{datasette_server}data/projects")

View file

@ -132,7 +132,11 @@ def test_path_from_row_pks(row, pks, expected_path):
"""
{"CategoryID": 1, "Description": "Soft drinks", "Picture": {"$base64": true, "encoded": "FRwCx60F/g=="}}
""".strip(),
)
),
(
{"message": b"hello"},
'{"message": {"$base64": true, "encoded": "aGVsbG8="}}',
),
],
)
def test_custom_json_encoder(obj, expected):