Add create table UI

Adds a permission-gated database action that opens a create table modal on database pages, backed by the existing create-table JSON API.

The modal starts with an id integer primary key column plus a blank text column, supports SQLite type selection, and shows custom column type controls only when the actor can set column types.

Selected custom column types are applied after table creation with follow-up set-column-type API calls. Includes styling plus HTML and Playwright coverage for the action payload and create-table flow.
This commit is contained in:
Simon Willison 2026-06-16 18:02:58 -07:00
commit 2d3c85dfc0
6 changed files with 1303 additions and 20 deletions

View file

@ -23,6 +23,23 @@ def table_data_from_soup(soup):
return json.loads(match.group(1))
def database_data_from_soup(soup):
import json
import re
database_script = [
s
for s in soup.find_all("script")
if "_datasetteDatabaseData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteDatabaseData\s*=\s*({.*?});",
database_script.string,
re.DOTALL,
)
return json.loads(match.group(1))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_definition_sql",
@ -934,6 +951,133 @@ async def test_row_delete_action_data_attributes():
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_action"), 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
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-database-action="create-table"]'
)
assert button is not None
assert button["aria-label"] == "Create table in data"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == "Create a new table in this database."
description.extract()
assert button.text.strip() == "Create table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
assert database_data_from_soup(soup) == {
"createTable": {
"path": "/data/-/create",
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
response_without_permission = await ds.client.get(
"/data", actor={"id": "someone-else"}
)
assert response_without_permission.status_code == 200
soup_without_permission = Soup(response_without_permission.text, "html.parser")
assert (
soup_without_permission.select_one(
'button[data-database-action="create-table"]'
)
is None
)
assert not any(
"_datasetteDatabaseData" in (script.string or "")
for script in soup_without_permission.find_all("script")
)
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
"set-column-type": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_custom_types"),
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["customColumnTypes"] == [
{
"name": "email",
"description": "Email address",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "json",
"description": "JSON data",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "textarea",
"description": "Multiline text",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "url",
"description": "URL",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_action_button_and_data():
ds = Datasette(