Unify create and alter table modal controls

Share default value controls between the create and alter table dialogs and expose create-table default expressions to the frontend.

Add create-table not-null/default handling and align the shared foreign key picker behavior across both dialogs.
This commit is contained in:
Simon Willison 2026-06-22 12:50:57 -07:00
commit fa43aba309
5 changed files with 707 additions and 170 deletions

View file

@ -1883,12 +1883,14 @@ select.table-create-input {
}
.table-create-foreign-key-target option,
.table-create-custom-column-type option {
.table-create-custom-column-type option,
.table-create-default-expr option {
color: var(--ink);
}
.table-create-foreign-key-target option[value=""],
.table-create-custom-column-type option[value=""] {
.table-create-custom-column-type option[value=""],
.table-create-default-expr option[value=""] {
color: var(--muted);
}
@ -1978,11 +1980,44 @@ select.table-create-input {
white-space: normal;
}
.table-create-not-null,
.table-create-primary-key,
.table-create-foreign-key-field {
.table-create-foreign-key-field,
.table-create-default-options {
grid-column: 1 / -1;
}
.table-create-default-options,
.table-alter-default-options {
border: 1px solid var(--rule);
border-radius: 5px;
background: #fff;
color: var(--ink);
min-width: 0;
}
.table-create-default-options > summary,
.table-alter-default-options > summary {
cursor: pointer;
color: var(--accent);
font-size: 0.85rem;
padding: 8px 10px;
}
.table-create-default-options > summary:focus,
.table-alter-default-options > summary:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 1px;
}
.table-create-default-grid,
.table-alter-default-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px 16px;
padding: 0 10px 10px;
}
.table-create-detail-check input {
flex: 0 0 auto;
margin: 0.15rem 0 0;
@ -2221,6 +2256,7 @@ dialog.table-alter-dialog::backdrop {
overflow-y: auto;
}
.table-alter-fields[hidden],
.table-alter-dialog .modal-footer [hidden] {
display: none;
@ -2358,12 +2394,14 @@ select.table-alter-input {
}
.table-alter-default-expr option,
.table-alter-custom-column-type option {
.table-alter-custom-column-type option,
.table-alter-foreign-key-target option {
color: var(--ink);
}
.table-alter-default-expr option[value=""],
.table-alter-custom-column-type option[value=""] {
.table-alter-custom-column-type option[value=""],
.table-alter-foreign-key-target option[value=""] {
color: var(--muted);
}
@ -2410,7 +2448,9 @@ select.table-alter-input {
}
.table-alter-not-null,
.table-alter-primary-key {
.table-alter-primary-key,
.table-alter-foreign-key-field,
.table-alter-default-options {
grid-column: 1 / -1;
}
@ -2657,6 +2697,10 @@ select.table-alter-input {
grid-template-columns: 1fr;
}
.table-alter-default-grid {
grid-template-columns: 1fr;
}
.table-alter-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
@ -2878,6 +2922,10 @@ select.table-alter-input {
grid-template-columns: 1fr;
}
.table-create-default-grid {
grid-template-columns: 1fr;
}
.table-create-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;

File diff suppressed because it is too large Load diff

View file

@ -266,6 +266,7 @@ async def _create_table_ui_context(
),
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": list(DEFAULT_EXPR_SQL),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",

View file

@ -295,6 +295,10 @@ def test_create_table_flow(page, datasette_server):
placeholder_select.locator("option:checked").inner_text() == "- custom type -"
)
assert "table-create-input-placeholder" in placeholder_select.get_attribute("class")
assert (
dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-create-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-create-column-name"
@ -306,6 +310,22 @@ def test_create_table_flow(page, datasette_server):
}""")
dialog.locator('input[name="table"]').fill("playwright_created")
dialog.locator(".table-create-column-name").nth(1).fill("title")
dialog.locator(".table-create-more-options").nth(1).click()
dialog.locator(".table-create-not-null-input").nth(1).check()
title_defaults = dialog.locator(".table-create-default-options").nth(1)
assert title_defaults.locator("summary").inner_text() == "Set a default value"
title_defaults.locator("summary").click()
assert "or default to a specific value" in title_defaults.inner_text()
title_default_expr = title_defaults.locator(".table-create-default-expr")
title_default_input = title_defaults.locator(".table-create-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in title_default_expr.locator("option").nth(1).inner_text()
)
title_default_expr.select_option("current_timestamp")
assert title_default_input.is_enabled()
title_default_input.fill("Untitled")
assert title_default_expr.input_value() == ""
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(2).fill("score")
dialog.locator(".table-create-column-type").nth(2).select_option("integer")
@ -337,6 +357,63 @@ def test_create_table_flow(page, datasette_server):
assert data["column_types"] == {
"metadata": {"type": "json", "config": None},
}
schema_response = httpx.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select sql from sqlite_master where type = 'table' "
"and name = 'playwright_created'"
)
},
)
schema_response.raise_for_status()
schema = schema_response.json()["rows"][0]["sql"]
assert "title" in schema
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(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-more-options").nth(1).click()
column_name = dialog.locator(".table-create-column-name").nth(1)
type_select = dialog.locator(".table-create-column-type").nth(1)
foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1)
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
column_name = dialog.locator(".table-alter-column-name").last
type_select = dialog.locator(".table-alter-column-type").last
foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
@ -349,6 +426,10 @@ def test_alter_table_flow(page, datasette_server):
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
assert dialog.locator(".table-alter-save").is_disabled()
assert (
dialog.locator(".table-alter-column-name").first.get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-alter-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-alter-column-name"
@ -377,15 +458,29 @@ def test_alter_table_flow(page, datasette_server):
)
assert "Not null" in expanded_options_text
assert "This value cannot be left unset" in expanded_options_text
assert "Default value" in expanded_options_text
assert "or default to a specific time" in expanded_options_text
assert "Set a default value" in expanded_options_text
assert "Primary key" in expanded_options_text
assert "This ID uniquely identifies the record" in expanded_options_text
assert "Foreign key" in expanded_options_text
first_defaults = dialog.locator(".table-alter-default-options").first
first_defaults.locator("summary").click()
assert "or default to a specific value" in first_defaults.inner_text()
first_default_expr = first_defaults.locator(".table-alter-default-expr")
first_default_input = first_defaults.locator(".table-alter-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in first_default_expr.locator("option").nth(1).inner_text()
)
first_default_expr.select_option("current_timestamp")
assert first_default_input.is_enabled()
first_default_input.fill("manual")
assert first_default_expr.input_value() == ""
dialog.locator(".table-alter-add-column").click()
assert dialog.locator(".table-alter-save").is_enabled()
dialog.locator(".table-alter-column-name").last.fill("status")
dialog.locator(".table-alter-column-type").last.select_option("text")
dialog.locator(".table-alter-default-options").last.locator("summary").click()
dialog.locator(".table-alter-default").last.fill("planned")
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")

View file

@ -997,6 +997,11 @@ async def test_database_create_table_action_button_and_data():
"foreignKeyTargetsPath": "/data/-/foreign-key-targets",
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": [
"current_timestamp",
"current_date",
"current_time",
],
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]