GET /db/table/-/insert returns data needed for dialog

As part of making the insert dialog less dependent on the table page.
This commit is contained in:
Simon Willison 2026-06-19 22:51:21 -07:00
commit a76646e3fd
4 changed files with 214 additions and 11 deletions

View file

@ -682,7 +682,7 @@ function columnFormControlContext(column, isPk, columnType, options) {
database: pageData.database || null,
table:
pageData.table ||
(tableInsertData() && tableInsertData().tableName) ||
(tableInsertData() && tableInsertData().table_name) ||
null,
tableUrl: pageData.tableUrl || null,
column: column,
@ -1542,7 +1542,7 @@ async function saveRowEditDialog(state) {
data && data.rows && data.rows.length ? data.rows[0] : null;
var insertedRowId = rowPathFromRowData(
insertedRowData,
insertData.primaryKeys || [],
insertData.primary_keys || [],
);
state.shouldRestoreFocus = false;
if (!insertedRowId) {
@ -1972,8 +1972,8 @@ function openRowInsertDialog(button, manager) {
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle(
state.title,
insertData.tableName
? "Insert row into " + insertData.tableName
insertData.table_name
? "Insert row into " + insertData.table_name
: "Insert row",
);
state.summary.hidden = true;

View file

@ -345,9 +345,9 @@ async def _table_insert_ui(
return {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"table_name": table_name,
"columns": columns,
"primaryKeys": pks,
"primary_keys": pks,
}
@ -655,6 +655,48 @@ class TableInsertView(BaseView):
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
if resolved.is_view:
return _error(["Cannot insert rows into a view"], 403)
if not db.is_mutable:
return _error(["Database is immutable"], 403)
if not await self.ds.allowed(
action="insert-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
pks = await db.primary_keys(table_name)
table_insert_ui = await _table_insert_ui(
self.ds, request, db, database_name, table_name, resolved.is_view, pks
)
return Response.json(
{
"ok": True,
"insert_row": table_insert_ui,
"table": {
"database": database_name,
"name": table_name,
"url": self.ds.urls.table(database_name, table_name),
},
"foreign_keys": await _foreign_key_autocomplete_urls(
self.ds, request, db, database_name, table_name
),
}
)
async def _validate_data(self, request, db, table_name, pks, upsert):
errors = []

View file

@ -1530,7 +1530,7 @@ Here's how to serve ``data.db`` with CORS enabled::
The JSON write API
------------------
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
Datasette provides a write API for JSON data. Write operations use ``POST`` and require an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
.. _ExecuteWriteView:
@ -1643,6 +1643,46 @@ Inserting rows
This requires the :ref:`actions_insert_row` permission.
To return metadata describing the insert form for a table, make a ``GET`` request:
::
GET /<database>/<table>/-/insert
Authorization: Bearer dstok_<rest-of-token>
This returns a JSON object describing the table, the columns that can be inserted and any foreign key autocomplete URLs available to the actor:
.. code-block:: json
{
"ok": true,
"insert_row": {
"path": "/data/dogs/-/insert",
"table_name": "dogs",
"columns": [
{
"name": "name",
"sqlite_type": "TEXT",
"notnull": 1,
"default": null,
"has_default": false,
"is_pk": false,
"value_kind": "string",
"column_type": null
}
],
"primary_keys": ["id"]
},
"table": {
"database": "data",
"name": "dogs",
"url": "/data/dogs"
},
"foreign_keys": {}
}
Integer primary key columns that SQLite can populate automatically are omitted from ``insert_row.columns``. If the actor does not have ``insert-row`` permission this endpoint returns a ``403`` response.
A single row can be inserted using the ``"row"`` key:
::

View file

@ -982,8 +982,8 @@ async def test_table_insert_action_button_and_data():
insert_data = table_data_from_soup(soup)["insertRow"]
assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items"
assert insert_data["primaryKeys"] == ["id"]
assert insert_data["table_name"] == "items"
assert insert_data["primary_keys"] == ["id"]
assert [column["name"] for column in insert_data["columns"]] == [
"name",
"score",
@ -1050,8 +1050,8 @@ async def test_table_insert_action_includes_compound_primary_keys():
insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[
"insertRow"
]
assert insert_data["tableName"] == "memberships"
assert insert_data["primaryKeys"] == ["account", "username"]
assert insert_data["table_name"] == "memberships"
assert insert_data["primary_keys"] == ["account", "username"]
assert [column["name"] for column in insert_data["columns"]] == [
"account",
"username",
@ -1066,6 +1066,127 @@ async def test_table_insert_action_includes_compound_primary_keys():
ds.close()
@pytest.mark.asyncio
async def test_table_insert_metadata_api():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"insert-row": {"id": "root"},
},
"column_types": {"body": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_metadata_api"), name="data"
)
await db.execute_write_script("""
create table authors (
id integer primary key,
name text
);
create table items (
id integer primary key,
author_id integer references authors(id),
name text not null,
score integer default 5,
body text
);
""")
response = await ds.client.get("/data/items/-/insert", actor={"id": "root"})
assert response.status_code == 200
assert response.json() == {
"ok": True,
"insert_row": {
"path": "/data/items/-/insert",
"table_name": "items",
"columns": [
{
"name": "author_id",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "number",
"column_type": None,
},
{
"name": "name",
"sqlite_type": "TEXT",
"notnull": 1,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "string",
"column_type": None,
},
{
"name": "score",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": "5",
"has_default": True,
"is_pk": False,
"value_kind": "number",
"column_type": None,
},
{
"name": "body",
"sqlite_type": "TEXT",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "string",
"column_type": {"type": "textarea", "config": None},
},
],
"primary_keys": ["id"],
},
"table": {
"database": "data",
"name": "items",
"url": "/data/items",
},
"foreign_keys": {
"author_id": "/data/authors/-/autocomplete",
},
}
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_metadata_api_requires_insert_permission():
ds = Datasette([])
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_metadata_permission"),
name="data",
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data/items/-/insert")
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied"],
}
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_data_includes_foreign_key_autocomplete_urls():
ds = Datasette([])