Add alter table modal

- Register a built-in table action and expose alter-table metadata to table pages.
- Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types.
- Add a review/apply confirmation flow with HTML and Playwright coverage.

Refs #2788
This commit is contained in:
Simon Willison 2026-06-17 09:21:09 -07:00
commit fdd1b61a3e
8 changed files with 2414 additions and 3 deletions

View file

@ -0,0 +1,29 @@
from datasette import hookimpl
from datasette.resources import TableResource
@hookimpl
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}
]
return inner

View file

@ -31,6 +31,7 @@ DEFAULT_PLUGINS = (
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.handle_exception",
"datasette.forbidden",

View file

@ -2032,6 +2032,505 @@ dialog.table-create-dialog::backdrop {
cursor: not-allowed;
}
dialog.table-alter-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(980px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(780px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.table-alter-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.table-alter-dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.table-alter-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.table-alter-dialog .modal-title {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.table-alter-form {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.table-alter-error {
border-left: 4px solid #b91c1c;
border-radius: 4px;
background: #fff1f1;
color: #7f1d1d;
font-size: 0.9rem;
margin: 12px 24px 0;
padding: 10px 12px;
}
.table-alter-error:focus {
outline: 3px solid rgba(185, 28, 28, 0.18);
outline-offset: 2px;
}
.table-alter-fields {
display: grid;
gap: 18px;
padding: 16px 24px 24px;
overflow-y: auto;
}
.table-alter-fields[hidden],
.table-alter-dialog .modal-footer [hidden] {
display: none;
}
.table-alter-review {
display: grid;
gap: 12px;
overflow-y: auto;
padding: 16px 24px 24px;
}
.table-alter-review[hidden] {
display: none;
}
.table-alter-review-title {
color: var(--ink);
font-size: 1rem;
line-height: 1.35;
margin: 0;
}
.table-alter-review-title:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 2px;
}
.table-alter-review-intro {
color: var(--muted);
font-size: 0.9rem;
margin: 0;
}
.table-alter-review-warning {
border-left: 4px solid #b91c1c;
border-radius: 4px;
background: #fff1f1;
color: #7f1d1d;
font-size: 0.9rem;
margin: 0;
padding: 10px 12px;
}
.table-alter-review-list {
display: grid;
gap: 8px;
margin: 0;
padding-left: 1.4rem;
}
.table-alter-review-list li {
color: var(--ink);
line-height: 1.4;
}
.table-alter-review-damaging {
font-weight: 600;
}
.table-alter-review-name {
background: #eef6ff;
border: 1px solid #c9ddf2;
border-radius: 4px;
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
padding: 1px 4px;
white-space: nowrap;
}
.table-alter-columns {
display: grid;
gap: 10px;
}
.table-alter-column-list {
display: grid;
gap: 8px;
}
.table-alter-column-headings,
.table-alter-column-main {
display: grid;
grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) max-content 32px;
align-items: center;
gap: 8px;
min-width: 0;
}
.table-alter-column-row {
display: grid;
gap: 8px;
min-width: 0;
}
.table-alter-column-headings {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
padding: 0 1px;
}
.table-alter-column-label {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.table-alter-input {
box-sizing: border-box;
min-width: 0;
border: 1px solid var(--rule);
border-radius: 5px;
padding: 8px 10px;
color: var(--ink);
background: #fff;
font: inherit;
}
.table-alter-input-placeholder {
color: var(--muted);
}
.table-alter-default-expr option,
.table-alter-custom-column-type option {
color: var(--ink);
}
.table-alter-default-expr option[value=""],
.table-alter-custom-column-type option[value=""] {
color: var(--muted);
}
.table-alter-input:focus {
border-color: var(--accent);
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.table-alter-column-details {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
gap: 12px 16px;
padding: 12px;
border-left: 3px solid var(--rule);
background: #f8fafc;
}
.table-alter-column-details[hidden] {
display: none;
}
.table-alter-detail-field {
display: grid;
gap: 4px;
min-width: 0;
}
.table-alter-detail-label {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
}
.table-alter-detail-check {
display: inline-flex;
align-items: flex-start;
gap: 8px;
color: var(--ink);
font-size: 0.85rem;
line-height: 1.35;
min-width: 0;
white-space: normal;
}
.table-alter-not-null,
.table-alter-primary-key {
grid-column: 1 / -1;
}
.table-alter-detail-check input {
flex: 0 0 auto;
margin: 0.15rem 0 0;
}
.table-alter-detail-check span {
min-width: 0;
overflow-wrap: break-word;
}
.table-alter-move-controls {
display: grid;
grid-template-columns: repeat(4, 32px);
gap: 4px;
justify-content: start;
}
.table-alter-more-options {
appearance: none;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
justify-self: start;
padding: 0;
grid-column: 1 / -1;
text-align: left;
}
.table-alter-more-options:hover,
.table-alter-more-options:focus {
text-decoration: underline;
}
.table-alter-more-options:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 2px;
}
.table-alter-more-options:disabled {
color: var(--muted);
cursor: default;
text-decoration: none;
}
.table-alter-icon-button {
appearance: none;
border: 1px solid rgba(74, 85, 104, 0.24);
background: transparent;
color: #4a5568;
border-radius: 4px;
cursor: pointer;
display: inline-grid;
place-items: center;
height: 32px;
width: 32px;
padding: 0;
}
.table-alter-icon-button:hover,
.table-alter-icon-button:focus {
background: rgba(74, 85, 104, 0.07);
}
.table-alter-icon-button:focus {
outline: 3px solid #b3d4ff;
outline-offset: 1px;
}
.table-alter-icon-button svg {
display: block;
}
.table-alter-add-column {
appearance: none;
justify-self: start;
border: 1px solid var(--rule);
border-radius: 5px;
background: #fff;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 7px 10px;
}
.table-alter-add-column:hover,
.table-alter-add-column:focus {
background: #f8fafc;
}
.table-alter-add-column:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 1px;
}
.table-alter-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.table-alter-dialog .btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.table-alter-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.table-alter-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.table-alter-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.table-alter-dialog .btn-primary:hover {
background: #1949b8;
}
.table-alter-dialog .btn-primary:disabled,
.table-alter-dialog .btn-primary:disabled:hover {
background: #a0aec0;
color: #fff;
}
.table-alter-dialog .btn:disabled,
.table-alter-add-column:disabled,
.table-alter-icon-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 900px) {
dialog.table-alter-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.table-alter-dialog .modal-header,
.table-alter-fields,
.table-alter-review {
padding-left: 18px;
padding-right: 18px;
}
.table-alter-error {
margin-left: 18px;
margin-right: 18px;
}
.table-alter-column-headings {
display: none;
}
.table-alter-column-row {
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
}
.table-alter-column-main {
grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px;
align-items: end;
}
.table-alter-column-name {
grid-column: 1;
grid-row: 1;
}
.table-alter-column-type {
grid-column: 2;
grid-row: 1;
}
.table-alter-remove-column {
grid-column: 3;
grid-row: 1;
justify-self: end;
}
.table-alter-custom-column-type {
grid-column: 1 / 3;
grid-row: 2;
}
.table-alter-move-controls {
grid-column: 1;
grid-row: 3;
justify-self: start;
}
.table-alter-more-options {
align-self: center;
grid-column: 2 / 4;
grid-row: 3;
}
.table-alter-column-details {
grid-template-columns: 1fr;
}
.table-alter-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
}
.row-link-with-actions {
display: inline-flex;
align-items: center;

File diff suppressed because it is too large Load diff

View file

@ -212,6 +212,7 @@ class RowView(DataView):
table,
not is_table,
None,
None,
),
"row_actions": row_actions,
"top_row": make_slot_function(

View file

@ -50,7 +50,7 @@ from datasette.filters import Filters
import sqlite_utils
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .database import QueryView, _custom_column_type_options_for_create_table
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
@ -62,6 +62,13 @@ LINK_WITH_LABEL = (
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a>&nbsp;<em>{id}</em>'
)
LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'
ALTER_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = {
SQLiteType.TEXT: "text",
SQLiteType.INTEGER: "integer",
SQLiteType.REAL: "float",
SQLiteType.BLOB: "blob",
}
class Row:
@ -283,7 +290,14 @@ async def _foreign_key_autocomplete_urls(
async def _table_page_data(
datasette, request, db, database_name, table_name, is_view, table_insert_ui
datasette,
request,
db,
database_name,
table_name,
is_view,
table_insert_ui,
table_alter_ui,
):
data = {
"database": database_name,
@ -292,6 +306,8 @@ async def _table_page_data(
}
if table_insert_ui:
data["insertRow"] = table_insert_ui
if table_alter_ui:
data["alterTable"] = table_alter_ui
if not is_view:
foreign_keys = await _foreign_key_autocomplete_urls(
datasette, request, db, database_name, table_name
@ -354,6 +370,63 @@ async def _table_insert_ui(
}
async def _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
):
if is_view or not db.is_mutable:
return None
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_types_map = await datasette.get_column_types(database_name, table_name)
columns = []
for column in await db.table_column_details(table_name):
if column.hidden:
continue
sqlite_type = SQLiteType.from_declared_type(column.type)
column_type = column_types_map.get(column.name)
columns.append(
{
"name": column.name,
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
"sqlite_type": sqlite_type.value,
"notnull": column.notnull,
"default": column.default_value,
"has_default": column.default_value is not None,
"is_pk": column.name in pks,
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
)
data = {
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
"defaultExpressions": list(DEFAULT_EXPR_SQL),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
if can_set_column_type:
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
datasette
)
return data
async def display_columns_and_rows(
datasette,
database_name,
@ -2421,7 +2494,11 @@ async def table_view_data(
table_insert_ui = await _table_insert_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
table_alter_ui = await _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
data["table_insert_ui"] = table_insert_ui
data["table_alter_ui"] = table_alter_ui
data["table_page_data"] = await _table_page_data(
datasette,
request,
@ -2430,6 +2507,7 @@ async def table_view_data(
table_name,
is_view,
table_insert_ui,
table_alter_ui,
)
return data, rows[:page_size], columns, expanded_columns, sql, next_url

View file

@ -130,6 +130,7 @@ def write_playwright_config(config_path):
"notes": "textarea",
},
"permissions": {
"alter-table": True,
"insert-row": True,
"update-row": True,
"delete-row": True,
@ -328,6 +329,215 @@ def test_create_table_flow(page, datasette_server):
}
@pytest.mark.playwright
def test_alter_table_flow(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()
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
assert dialog.locator(".table-alter-save").is_disabled()
type_options = dialog.locator(".table-alter-column-type").first.locator("option")
assert type_options.all_inner_texts() == [
"text",
"integer",
"floating point number",
"blob - binary data",
]
first_more_options = dialog.locator(".table-alter-more-options").first
assert first_more_options.inner_text() == "> Advanced options"
first_more_options.click()
assert first_more_options.inner_text() == "v Hide options"
expanded_options_text = dialog.locator(".table-alter-column-details").first.inner_text()
assert dialog.locator(".table-alter-fields").evaluate(
"node => node.scrollWidth <= node.clientWidth + 1"
)
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 "Primary key" in expanded_options_text
assert "An ID that uniquely identifies this record" in expanded_options_text
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").last.fill("planned")
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Add column status as text, with default value planned." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-back").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Apply changes"
dialog.locator(".table-alter-save").click()
columns = []
for _ in range(20):
response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns")
response.raise_for_status()
columns = response.json()["columns"]
if "status" in columns:
break
time.sleep(0.1)
assert "status" in columns
@pytest.mark.playwright
def test_alter_table_primary_key_columns_stay_at_top(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()
rows = dialog.locator(".table-alter-column-row")
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button")
for i in range(first_row_move_buttons.count()):
assert first_row_move_buttons.nth(i).is_disabled()
assert (
first_row_move_buttons.nth(i).get_attribute("title")
== "Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").is_disabled()
assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == (
"Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == (
"Primary key columns are always listed first"
)
last_row = rows.nth(rows.count() - 1)
assert last_row.locator(".table-alter-column-name").input_value() == "score"
last_row.locator(".table-alter-move-top").click()
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score"
@pytest.mark.playwright
def test_alter_table_review_rename_primary_key_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()
save = dialog.locator(".table-alter-save")
assert save.is_disabled()
dialog.locator(".table-alter-column-name").first.fill("id3")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
review_text = review.inner_text()
assert "Rename column id to id3." in review_text
assert "Set primary key to" not in review_text
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"id",
"id3",
]
@pytest.mark.playwright
def test_alter_table_review_not_null_wording(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-more-options").first.click()
dialog.locator(".table-alter-not-null-input").first.check()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Change column id: not null (require values)." in review.inner_text()
@pytest.mark.playwright
def test_alter_table_review_warns_when_dropping_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()
remove_buttons = dialog.locator(".table-alter-remove-column")
remove_buttons.nth(remove_buttons.count() - 1).click()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Warning: data in dropped columns will be permanently lost." in review_text
assert "Drop column score." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-review-damaging").inner_text() == (
"Drop column score."
)
dialog.locator(".table-alter-back").click()
assert dialog.locator(".table-alter-column-list").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Review changes"
@pytest.mark.playwright
def test_alter_table_cancel_skips_discard_prompt(page, datasette_server):
def open_alter_dialog():
page.locator("details.actions-menu-links").evaluate("node => node.open = true")
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
return dialog
page.goto(f"{datasette_server}data/projects")
page.evaluate(
"""
() => {
window.__discardConfirmMessages = [];
window.confirm = (message) => {
window.__discardConfirmMessages.push(message);
return false;
};
}
"""
)
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("cancel_me")
dialog.locator(".table-alter-cancel").click()
assert dialog.evaluate("node => node.open") is False
assert page.evaluate("() => window.__discardConfirmMessages") == []
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("escape_me")
page.keyboard.press("Escape")
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
page.evaluate("() => window.__discardConfirmMessages = []")
dialog.evaluate(
"""node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))"""
)
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
@pytest.mark.playwright
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
page.goto(datasette_server)

View file

@ -1078,6 +1078,123 @@ async def test_database_create_table_data_includes_custom_column_types():
ds.close()
@pytest.mark.asyncio
async def test_table_alter_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"alter-table": {"id": "root"},
"set-column-type": {"id": "root"},
},
"column_types": {"name": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_alter_action"), name="data"
)
await db.execute_write_script("""
create table items (
id integer primary key,
name text not null,
score integer default 5
);
""")
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-table-action="alter-table"]'
)
assert button is not None
assert button["aria-label"] == "Alter table items"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == (
"Change columns and primary key for this table."
)
description.extract()
assert button.text.strip() == "Alter table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
alter_data = table_data_from_soup(soup)["alterTable"]
assert alter_data["path"] == "/data/items/-/alter"
assert alter_data["tableName"] == "items"
assert alter_data["primaryKeys"] == ["id"]
assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"]
assert alter_data["defaultExpressions"] == [
"current_timestamp",
"current_date",
"current_time",
]
assert [option["name"] for option in alter_data["customColumnTypes"]] == [
"email",
"json",
"textarea",
"url",
]
assert alter_data["columns"] == [
{
"name": "id",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": True,
"column_type": None,
},
{
"name": "name",
"type": "text",
"sqlite_type": "TEXT",
"notnull": 1,
"default": None,
"has_default": False,
"is_pk": False,
"column_type": {"type": "textarea", "config": None},
},
{
"name": "score",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": "5",
"has_default": True,
"is_pk": False,
"column_type": None,
},
]
response_without_permission = await ds.client.get(
"/data/items", 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-table-action="alter-table"]'
)
is None
)
assert "alterTable" not in table_data_from_soup(soup_without_permission)
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_action_button_and_data():
ds = Datasette(