New .insertDialog() JavaScript method

Anywhere in Datasette can now open an insert dialog
pre-populated with suggested data.

Using it in one place already, on the row page
to provide an insert button for related rows.
This commit is contained in:
Simon Willison 2026-06-19 23:40:15 -07:00
commit 76e6a3bc38
13 changed files with 791 additions and 52 deletions

View file

@ -700,7 +700,7 @@ def test_config_force_https_urls():
("/fixtures/-/query.json?sql=select+blah", 400),
# Write APIs
("/fixtures/-/create", 405),
("/fixtures/facetable/-/insert", 405),
("/fixtures/facetable/-/insert", 403),
("/fixtures/facetable/-/drop", 405),
],
)

View file

@ -1595,7 +1595,6 @@ async def test_create_table_error_rows_twice_with_duplicates(ds_write):
(
"/data/-/create",
"/data/docs/-/drop",
"/data/docs/-/insert",
),
)
async def test_method_not_allowed(ds_write, path):

View file

@ -90,20 +90,29 @@ def write_playwright_database(db_path):
conn = sqlite3.connect(db_path)
try:
conn.executescript("""
create table licenses (
key text primary key,
name text
);
insert into licenses (key, name) values
('mit', 'MIT License'),
('apache-2.0', 'Apache License 2.0');
create table projects (
id integer primary key,
title text not null,
metadata text,
logo text,
notes text,
license text references licenses(key),
score integer default 5
);
insert into projects (title, metadata, logo, notes, score) values
insert into projects (title, metadata, logo, notes, license, score) values
(
'Build Datasette',
'{"ok": true}',
'asset-original',
'Initial notes',
'mit',
5
);
""")
@ -347,6 +356,75 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
assert data["score"] == 5
@pytest.mark.playwright
def test_insert_dialog_javascript_api_returns_inserted_row(page, datasette_server):
page.goto(datasette_server)
page.wait_for_function("window.datasette && window.datasette.insertDialog")
assert not page.locator('script[src*="edit-tools.js"]').count()
page.evaluate("""
() => {
window.insertDialogResult = window.datasette.insertDialog(
"data",
"projects",
{
title: "Suggested from JavaScript",
metadata: '{"suggested": true}',
notes: "Created through insertDialog()",
license: "apache-2.0",
score: 7
},
"Review this suggested project before inserting it."
);
}
""")
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
assert "Insert row into projects" in dialog.locator("#row-edit-title").inner_text()
assert (
dialog.locator("#row-edit-summary").inner_text()
== "Review this suggested project before inserting it."
)
assert dialog.locator('input[name="title"]').input_value() == (
"Suggested from JavaScript"
)
assert dialog.locator('textarea[name="metadata"]').input_value() == (
'{"suggested": true}'
)
assert dialog.locator('textarea[name="notes"]').input_value() == (
"Created through insertDialog()"
)
assert page.evaluate("() => !!customElements.get('datasette-autocomplete')")
assert (
dialog.locator('datasette-autocomplete input[name="license"]').input_value()
== "apache-2.0"
)
assert dialog.locator('input[name="score"]').input_value() == "7"
dialog.locator(".row-edit-save").click()
result = page.evaluate("() => window.insertDialogResult")
assert result["ok"] is True
assert result["status"] == "inserted"
assert result["database"] == "data"
assert result["table"] == "projects"
assert result["row_id"] == "2"
assert result["row_path"] == "2"
assert result["row_url"].endswith("/data/projects/2")
assert result["row"]["title"] == "Suggested from JavaScript"
assert result["row"]["metadata"] == '{"suggested": true}'
assert result["row"]["notes"] == "Created through insertDialog()"
assert result["row"]["license"] == "apache-2.0"
assert result["row"]["score"] == 7
data = project_row(datasette_server, 2)
assert data["title"] == "Suggested from JavaScript"
assert data["metadata"] == '{"suggested": true}'
assert data["notes"] == "Created through insertDialog()"
assert data["license"] == "apache-2.0"
assert data["score"] == 7
@pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")

View file

@ -1242,6 +1242,76 @@ async def test_table_data_includes_foreign_key_autocomplete_urls():
ds.close()
@pytest.mark.asyncio
async def test_row_foreign_key_tables_include_insert_dialog_button():
import json
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"repos": {
"permissions": {
"insert-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_fk_insert_dialog_button"),
name="data",
)
await db.execute_write_script("""
create table licenses (
key text primary key,
name text
);
create table repos (
id integer primary key,
name text,
license text references licenses(key)
);
insert into licenses (key, name)
values ('apache-2.0', 'Apache License 2.0');
insert into repos (name, license)
values ('datasette', 'apache-2.0');
""")
response = await ds.client.get(
"/data/licenses/apache-2~2E0", actor={"id": "root"}
)
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one('button[data-insert-dialog][data-table="repos"]')
assert button is not None
assert button.text.strip() == "Insert"
assert button["class"] == ["core", "row-foreign-key-insert"]
assert button.has_attr("data-insert-dialog-reload")
assert button["data-database"] == "data"
assert json.loads(button["data-row"]) == {"license": "apache-2.0"}
assert (
button["data-message"]
== "Insert a row in repos with license set to apache-2.0."
)
assert not any(
"edit-tools.js" in (script.get("src") or "")
for script in soup.find_all("script")
)
response = await ds.client.get("/data/licenses/apache-2~2E0")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert soup.select_one("button[data-insert-dialog]") is None
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_fragment_endpoint(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
@ -1384,6 +1454,48 @@ async def test_row_delete_redirect_to_table_sets_message():
ds.close()
@pytest.mark.asyncio
async def test_table_insert_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"insert-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_message"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.post(
"/data/items/-/insert?_message=1",
actor={"id": "root"},
json={"row": {"name": "One"}},
)
assert response.status_code == 201
assert response.json() == {
"ok": True,
"rows": [{"id": 1, "name": "One"}],
}
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Inserted row 1 (One)", ds.INFO]
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_row_update_sets_message():
ds = Datasette(