Bulk row inserts / create table from data

Merge pull request #2813 from simonw/bulk-insert
This commit is contained in:
Simon Willison 2026-07-03 13:07:42 -07:00 committed by GitHub
commit c9975c1fe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 3015 additions and 163 deletions

View file

@ -1641,6 +1641,11 @@ dialog.row-edit-dialog::backdrop {
overflow-y: auto;
}
.row-edit-fields[hidden],
.row-edit-bulk[hidden] {
display: none;
}
.row-edit-field {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
@ -1798,6 +1803,207 @@ textarea.row-edit-input {
margin: 0;
}
.row-edit-bulk {
display: grid;
gap: 8px;
padding: 16px 24px 24px;
overflow-y: auto;
}
.row-edit-bulk-editor {
display: grid;
gap: 8px;
}
.row-edit-bulk-editor[hidden] {
display: none;
}
.row-edit-bulk-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
}
.row-edit-bulk-actions .btn {
padding-left: 12px;
padding-right: 12px;
}
.row-edit-bulk-conflict {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
gap: 8px 12px;
align-items: start;
}
.row-edit-bulk-conflict[hidden] {
display: none;
}
.row-edit-bulk-conflict-label {
color: var(--ink);
font-size: 0.82rem;
padding-top: 8px;
}
.row-edit-bulk-conflict-control {
display: grid;
gap: 4px;
}
.row-edit-bulk-conflict-help {
color: var(--muted);
font-size: 0.78rem;
margin: 0;
}
.row-edit-copy-template-label-narrow {
display: none;
}
.row-edit-bulk-template-note {
color: var(--muted);
font-size: 0.82rem;
}
.row-edit-bulk-template-note-narrow {
display: none;
}
.row-edit-bulk-textarea {
min-height: 16rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.45;
}
.row-edit-bulk-textarea.row-edit-bulk-drop-target {
border-color: var(--accent);
background: #f8fbff;
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.row-edit-bulk-note {
color: var(--muted);
font-size: 0.82rem;
margin: 0;
}
.row-edit-bulk-note label,
.row-edit-bulk-note .button-as-link {
font: inherit;
}
@media (max-width: 640px) {
.row-edit-copy-template-label-wide {
display: none;
}
.row-edit-copy-template-label-narrow {
display: inline;
}
.row-edit-bulk-template-note-wide {
display: none;
}
.row-edit-bulk-template-note-narrow {
display: inline;
}
}
.row-edit-bulk-preview {
display: grid;
gap: 8px;
margin-top: 8px;
}
.row-edit-bulk-preview[hidden] {
display: none;
}
.row-edit-bulk-preview-summary {
color: var(--ink);
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.row-edit-bulk-preview-table-wrap {
border: 1px solid var(--rule);
border-radius: 5px;
max-height: 18rem;
overflow: auto;
background: #fff;
}
.row-edit-bulk-preview-table {
border-collapse: collapse;
font-size: 0.78rem;
min-width: 100%;
width: max-content;
}
.row-edit-bulk-preview-table th,
.row-edit-bulk-preview-table td {
border-bottom: 1px solid var(--rule);
border-right: 1px solid var(--rule);
max-width: 18rem;
overflow-wrap: anywhere;
padding: 6px 8px;
text-align: left;
vertical-align: top;
white-space: normal;
}
.row-edit-bulk-preview-table th {
background: var(--paper);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.row-edit-bulk-preview-table tr:last-child td {
border-bottom: none;
}
.row-edit-bulk-preview-table th:last-child,
.row-edit-bulk-preview-table td:last-child {
border-right: none;
}
.row-edit-bulk-preview-null,
.row-edit-bulk-preview-auto {
color: var(--muted);
font-style: italic;
}
.row-edit-bulk-progress {
display: grid;
gap: 6px;
}
.row-edit-bulk-progress[hidden] {
display: none;
}
.row-edit-bulk-progress-bar {
width: 100%;
}
.row-edit-bulk-progress-status {
color: var(--ink);
font-size: 0.9rem;
margin: 0;
}
datasette-autocomplete {
display: block;
position: relative;
@ -1876,6 +2082,16 @@ datasette-autocomplete input[type="text"],
background: var(--paper);
}
.row-edit-mode-link {
color: var(--accent);
font-size: 0.9rem;
margin-right: auto;
}
.row-edit-mode-link[hidden] {
display: none;
}
.row-edit-dialog .btn {
border: none;
border-radius: 5px;
@ -2072,6 +2288,120 @@ select.table-create-input {
gap: 10px;
}
.table-create-columns[hidden],
.table-create-data[hidden],
.table-create-data-editor[hidden],
.table-create-data-preview[hidden] {
display: none;
}
.table-create-data,
.table-create-data-editor {
display: grid;
gap: 8px;
}
.table-create-data-label {
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.table-create-data-textarea {
min-height: 16rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.45;
}
.table-create-data-textarea.table-create-data-drop-target {
border-color: var(--accent);
background: #f8fbff;
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.table-create-data-note {
color: var(--muted);
font-size: 0.82rem;
margin: 0;
}
.table-create-data-note label,
.table-create-data-note .button-as-link {
font: inherit;
}
.table-create-data-preview {
display: grid;
gap: 10px;
}
.table-create-data-preview-summary {
color: var(--ink);
font-size: 0.9rem;
font-weight: 600;
margin: 0;
}
.table-create-data-pk-field {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.table-create-data-preview-table-wrap {
border: 1px solid var(--rule);
border-radius: 5px;
max-height: 18rem;
overflow: auto;
background: #fff;
}
.table-create-data-preview-table {
border-collapse: collapse;
font-size: 0.78rem;
min-width: 100%;
width: max-content;
}
.table-create-data-preview-table th,
.table-create-data-preview-table td {
border-bottom: 1px solid var(--rule);
border-right: 1px solid var(--rule);
max-width: 18rem;
overflow-wrap: anywhere;
padding: 6px 8px;
text-align: left;
vertical-align: top;
white-space: normal;
}
.table-create-data-preview-table th {
background: var(--paper);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.table-create-data-preview-table tr:last-child td {
border-bottom: none;
}
.table-create-data-preview-table th:last-child,
.table-create-data-preview-table td:last-child {
border-right: none;
}
.table-create-data-preview-null {
color: var(--muted);
font-style: italic;
}
.table-create-column-list {
display: grid;
gap: 8px;
@ -2299,6 +2629,16 @@ select.table-create-input {
background: var(--paper);
}
.table-create-mode-link {
color: var(--accent);
font-size: 0.9rem;
margin-right: auto;
}
.table-create-mode-link[hidden] {
display: none;
}
.table-create-dialog .btn {
border: none;
border-radius: 5px;
@ -3012,7 +3352,8 @@ select.table-alter-input {
.row-edit-dialog .modal-header,
.row-edit-summary,
.row-edit-loading,
.row-edit-fields {
.row-edit-fields,
.row-edit-bulk {
padding-left: 18px;
padding-right: 18px;
}
@ -3031,6 +3372,15 @@ select.table-alter-input {
padding-top: 0;
}
.row-edit-bulk-conflict {
grid-template-columns: 1fr;
gap: 5px;
}
.row-edit-bulk-conflict-label {
padding-top: 0;
}
.row-edit-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
@ -3062,6 +3412,11 @@ select.table-alter-input {
padding-top: 0;
}
.table-create-data-pk-field {
grid-template-columns: 1fr;
gap: 5px;
}
.table-create-column-headings {
display: none;
}

File diff suppressed because it is too large Load diff

View file

@ -325,7 +325,7 @@ class DatabaseContext(Context):
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
}
)
database_actions: callable = field(

View file

@ -205,7 +205,7 @@ class TableContext(Context):
)
table_insert_ui: dict = field(
metadata={
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
}
)
table_alter_ui: dict = field(
@ -480,8 +480,15 @@ async def _table_insert_ui(
):
return None
can_update = await datasette.allowed(
action="update-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
column_types_map = await datasette.get_column_types(database_name, table_name)
columns = []
bulk_columns = []
column_details = await db.table_column_details(table_name)
for column in column_details:
if column.hidden:
@ -492,32 +499,40 @@ async def _table_insert_ui(
and len(pks) == 1
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
)
column_type = column_types_map.get(column.name)
column_data = {
"name": column.name,
"sqlite_type": _column_sqlite_type_for_insert_form(column),
"notnull": column.notnull,
"default": column.default_value,
"has_default": column.default_value is not None,
"is_pk": is_pk,
"is_auto_pk": is_auto_pk,
"value_kind": _column_value_kind_for_insert_form(column),
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
bulk_columns.append(column_data)
if is_auto_pk:
continue
column_type = column_types_map.get(column.name)
columns.append(
{
"name": column.name,
"sqlite_type": _column_sqlite_type_for_insert_form(column),
"notnull": column.notnull,
"default": column.default_value,
"has_default": column.default_value is not None,
"is_pk": is_pk,
"value_kind": _column_value_kind_for_insert_form(column),
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
)
columns.append(column_data)
return {
data = {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"bulkColumns": bulk_columns,
"primaryKeys": pks,
"maxInsertRows": datasette.setting("max_insert_rows"),
}
if can_update:
data["upsertPath"] = "{}/-/upsert".format(
datasette.urls.table(database_name, table_name)
)
return data
async def _table_alter_ui(

View file

@ -267,6 +267,11 @@ async def _create_table_ui_context(
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
"canInsertRows": await datasette.allowed(
action="insert-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",

View file

@ -4,6 +4,15 @@
Changelog
=========
.. _unreleased:
Unreleased
----------
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
.. _v1_0_a35:
1.0a35 (2026-06-23)

View file

@ -98,7 +98,7 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
The color assigned to the database
``database_page_data`` - ``dict``
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
``editable`` - ``bool``
Boolean indicating if the database is editable
@ -389,7 +389,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
SQL definition for this table
``table_insert_ui`` - ``dict``
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
``table_page_data`` - ``dict``
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.

View file

@ -17,7 +17,7 @@ def find_free_port():
return sock.getsockname()[1]
def wait_for_server(process, url, timeout=10):
def wait_for_server(process, url, timeout=30):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
@ -36,7 +36,20 @@ def wait_for_server(process, url, timeout=10):
except httpx.HTTPError as ex:
last_error = repr(ex)
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
if process.poll() is None:
process.terminate()
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
stdout, stderr = process.communicate()
else:
stdout, stderr = process.communicate()
raise AssertionError(
f"Timed out waiting for {url}: {last_error}\n"
f"stdout:\n{stdout}\n"
f"stderr:\n{stderr}"
)
@pytest.fixture
@ -102,6 +115,17 @@ def write_playwright_database(db_path):
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
create table bulk_defaults (
id integer primary key,
title text not null,
status text not null default 'todo',
score integer default 5
);
create table upsert_items (
id text primary key,
title text,
metadata text
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
@ -110,6 +134,8 @@ def write_playwright_database(db_path):
'Initial notes',
5
);
insert into upsert_items (id, title, metadata) values
('existing', 'Existing title', '{"old": true}');
""")
finally:
conn.close()
@ -123,6 +149,7 @@ def write_playwright_config(config_path):
"data": {
"permissions": {
"create-table": True,
"insert-row": True,
"set-column-type": True,
},
"tables": {
@ -145,6 +172,17 @@ def write_playwright_config(config_path):
"alter-table": True,
},
},
"bulk_defaults": {
"permissions": {
"insert-row": True,
},
},
"upsert_items": {
"permissions": {
"insert-row": True,
"update-row": True,
},
},
},
},
},
@ -278,6 +316,41 @@ def project_row(datasette_server, pk):
return rows[0]
def bulk_default_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_row(datasette_server, pk):
rows = upsert_item_rows(datasette_server, id=pk)
assert len(rows) == 1
return rows[0]
def open_bulk_insert_dialog(page, url):
page.goto(url)
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
return dialog
def open_jump_menu(page):
page.keyboard.press("/")
page.locator("navigation-search .search-input").wait_for()
@ -381,6 +454,165 @@ def test_create_table_flow(page, datasette_server):
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_from_data_flow(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
from_data = dialog.locator(".table-create-from-data")
assert from_data.inner_text() == "Create table from data"
from_data.click()
assert dialog.locator(".table-create-columns").is_hidden()
assert dialog.locator(".table-create-data").is_visible()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
assert (
dialog.locator(".table-create-data-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
assert (
dialog.locator(".table-create-data-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== ("table-create-data-note table-create-input table-create-data-textarea")
)
assert dialog.locator(".table-create-manual").inner_text() == (
"Create table manually"
)
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "table-create-data-textarea"
)
textarea = dialog.locator(".table-create-data-textarea")
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "short_id,name\nx,Ada"
assert dialog.locator(".table-create-table-name").input_value() == (
"repo_export_2026"
)
dialog.locator(".table-create-table-name").fill("playwright_from_data")
textarea.fill(
json.dumps(
{
"metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
"people": [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
],
}
)
)
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-data-textarea").is_hidden()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
assert dialog.locator(".table-create-cancel").inner_text() == "Back"
assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
"Previewing 2 rows."
)
assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "short_id" in preview_text
assert "Ada" in preview_text
assert "2.5" in preview_text
preview_cell_style = dialog.locator(
".table-create-data-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
dialog.locator(".table-create-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".table-create-data-textarea").is_visible()
assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_from_data")
response = httpx.get(
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
)
response.raise_for_status()
data = response.json()
assert data["rows"] == [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
]
@pytest.mark.playwright
def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
page, datasette_server
):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
dialog.locator(".table-create-from-data").click()
dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "A" in preview_text
assert "1" in preview_text
assert "B" in preview_text
assert "null" in preview_text
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_numeric_blanks")
response = httpx.get(
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
)
response.raise_for_status()
assert response.json()["rows"] == [
{"name": "A", "score": 1},
{"name": "B", "score": None},
]
schema_response = httpx.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select type from pragma_table_info('playwright_numeric_blanks') "
"where name = 'score'"
)
},
)
schema_response.raise_for_status()
assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
page.goto(f"{datasette_server}data")
@ -801,11 +1033,128 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins(
@pytest.mark.playwright
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
page.add_init_script("""
(() => {
let clipboardText = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({
writeText: async (text) => {
clipboardText = String(text);
},
readText: async () => clipboardText,
}),
});
})();
""")
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
bulk_insert = dialog.locator(".row-edit-bulk-insert")
bulk_insert.wait_for()
assert bulk_insert.inner_text() == "Insert multiple rows"
current_url = page.url
bulk_insert.click()
assert page.url == current_url
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-fields").is_hidden()
assert dialog.locator(".row-edit-bulk").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
assert (
dialog.locator(".row-edit-bulk-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
assert (
dialog.locator(".row-edit-bulk-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== (
"row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
"row-edit-bulk-actions"
)
)
copy_template = dialog.locator(".row-edit-copy-template")
assert copy_template.inner_text() == "Copy spreadsheet template"
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "row-edit-bulk-textarea"
)
assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
"Copy template"
)
assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
"You can paste the template into Google Sheets or Excel."
)
assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
"Paste into Google Sheets or Excel"
)
copy_template.click()
page.wait_for_function(
"""() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
)
assert page.evaluate("navigator.clipboard.readText()") == (
"title\tmetadata\tlogo\tnotes\tscore"
)
textarea = dialog.locator(".row-edit-bulk-textarea")
textarea.fill("title\tmetadata\nFrom TSV\t{}")
assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
dialog.locator(".row-edit-save").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 1 row."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "id" in preview_text
assert "title" in preview_text
assert "metadata" in preview_text
assert "From CSV" in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
assert "null" not in preview_text
assert "undefined" not in preview_text
preview_cell_style = dialog.locator(
".row-edit-bulk-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
dialog.locator(".row-edit-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_visible()
assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
"\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
)
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
single_insert = dialog.locator(".row-edit-single-insert")
assert single_insert.inner_text() == "Insert single row"
single_insert.click()
assert dialog.locator(".row-edit-bulk").is_hidden()
assert dialog.locator(".row-edit-fields").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
dialog.locator('textarea[name="metadata"]').fill(
'{"ok": false, "source": "playwright"}'
@ -835,6 +1184,206 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
assert data["score"] == 5
@pytest.mark.playwright
def test_bulk_insert_preview_inserts_rows(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill(
json.dumps(
{
"metadata": [{"title": "Ignored", "metadata": "{}"}],
"projects": [
{"title": "Bulk one", "metadata": "{}"},
{"title": "Bulk two", "metadata": "{}"},
],
}
)
)
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows inserted."
).wait_for()
assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
dialog.locator(".row-edit-cancel").click()
page.wait_for_load_state("domcontentloaded")
assert page.url == f"{datasette_server}data/projects"
assert project_rows(datasette_server, title="Bulk one")
assert project_rows(datasette_server, title="Bulk two")
@pytest.mark.playwright
def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
assert conflict_field.is_hidden()
textarea.fill("title\nNo primary key")
assert conflict_field.is_hidden()
textarea.fill(
"id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
)
assert conflict_field.is_visible()
assert (
dialog.locator(".row-edit-bulk-conflict-label").inner_text()
== "If the row exists already"
)
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
"Update existing and insert new",
]
)
conflict_select.select_option("upsert")
assert conflict_select.input_value() == "upsert"
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Updated by upsert" in preview_text
assert "Inserted by upsert" in preview_text
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows upserted."
).wait_for()
assert upsert_item_row(datasette_server, "existing")["title"] == (
"Updated by upsert"
)
assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
@pytest.mark.playwright
def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
textarea.fill("title\nOnly title")
assert conflict_field.is_hidden()
textarea.fill("id,title\n1,Only title")
assert conflict_field.is_visible()
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
]
)
assert conflict_select.locator('option[value="upsert"]').evaluate(
"node => node.hidden && node.disabled"
)
@pytest.mark.playwright
def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
textarea = dialog.locator(".row-edit-bulk-textarea")
error = dialog.locator(".row-edit-error")
save = dialog.locator(".row-edit-save")
textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
error.wait_for()
assert error.inner_text() == "JSON row 1 has unknown column id2."
assert save.is_disabled()
assert textarea.evaluate("node => document.activeElement === node")
textarea.fill("id2,title\n1,Unknown column")
assert error.inner_text() == "Unknown column id2 in header row."
assert save.is_disabled()
textarea.fill("[")
assert error.is_hidden()
assert not save.is_disabled()
textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
assert error.is_hidden()
assert not save.is_disabled()
assert dialog.locator(".row-edit-bulk-conflict").is_visible()
assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
@pytest.mark.playwright
def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
page.goto(f"{datasette_server}data/bulk_defaults")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Only title" in preview_text
assert "undefined" not in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="1 row inserted."
).wait_for()
rows = bulk_default_rows(datasette_server, title="Only title")
assert rows == [
{
"id": 1,
"title": "Only title",
"status": "todo",
"score": 5,
}
]
@pytest.mark.playwright
def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 3 rows."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "one" in preview_text
assert "two" in preview_text
assert "three" in preview_text
assert "null" not in preview_text
assert "undefined" not in preview_text
@pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
@ -842,6 +1391,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
assert dialog.locator(".row-edit-bulk-insert").is_hidden()
assert dialog.locator(".row-edit-single-insert").is_hidden()
assert dialog.locator(".row-edit-bulk").is_hidden()
title = dialog.locator('input[name="title"]')
title.wait_for()
title.fill("Build Datasette, edited")

View file

@ -1027,6 +1027,7 @@ async def test_database_create_table_action_button_and_data():
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
"canInsertRows": False,
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
@ -1050,6 +1051,40 @@ async def test_database_create_table_action_button_and_data():
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_insert_row_permission():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
"insert-row": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_insert_permission"),
name="data",
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data", actor={"id": "root"})
assert response.status_code == 200
create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
"createTable"
]
assert create_table_data["canInsertRows"] is True
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
@ -1316,6 +1351,7 @@ async def test_table_insert_action_button_and_data():
assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items"
assert insert_data["primaryKeys"] == ["id"]
assert insert_data["maxInsertRows"] == 100
assert [column["name"] for column in insert_data["columns"]] == [
"name",
"score",