Add rename table controls to alter table dialog

Add a collapsed rename-table section to the alter table modal and include rename_table operations in the review/apply flow.

Redirect to the renamed table URL after applying changes and cover the review text in Playwright.
This commit is contained in:
Simon Willison 2026-06-22 12:51:23 -07:00
commit 2ebae5ed71
3 changed files with 121 additions and 6 deletions

View file

@ -2256,6 +2256,28 @@ dialog.table-alter-dialog::backdrop {
overflow-y: auto;
}
.table-alter-table-options {
border-top: 1px solid var(--rule);
padding-top: 12px;
}
.table-alter-table-options > summary {
color: var(--ink);
cursor: pointer;
font-weight: 600;
}
.table-alter-table-options > summary:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 3px;
}
.table-alter-table-name-field {
display: grid;
gap: 4px;
margin-top: 10px;
max-width: 24rem;
}
.table-alter-fields[hidden],
.table-alter-dialog .modal-footer [hidden] {

View file

@ -1822,6 +1822,7 @@ function tableAlterDialogSignature(state) {
return "";
}
return JSON.stringify({
tableName: state.tableNameInput ? state.tableNameInput.value.trim() : "",
columns: tableAlterDialogRows(state).map(tableAlterRowSignature),
deletedColumns: state.deletedColumns.slice(),
});
@ -2307,6 +2308,9 @@ function addTableAlterColumn(state, column) {
function resetTableAlterDialog(state, data) {
state.nextColumnIndex = 0;
state.deletedColumns = [];
state.originalTableName = data.tableName || "";
state.tableNameInput.value = state.originalTableName;
state.tableOptions.open = false;
state.originalPrimaryKeys = (data.primaryKeys || []).slice();
state.originalColumnNames = (data.columns || []).map(function (column) {
return column.name;
@ -2340,6 +2344,16 @@ function collectTableAlterRows(state) {
}
function validateTableAlterRows(state, rows) {
var tableName = state.tableNameInput.value.trim();
if (!tableName) {
return "Table name is required.";
}
if (tableName.indexOf("\n") !== -1) {
return "Table names cannot contain newlines.";
}
if (/^sqlite_/.test(tableName)) {
return "Table names cannot start with sqlite_.";
}
if (!rows.length) {
return "At least one column is required.";
}
@ -2513,6 +2527,13 @@ function collectTableAlterPayload(state) {
}
var operations = [];
var tableName = state.tableNameInput.value.trim();
if (tableName !== state.originalTableName) {
operations.push({
op: "rename_table",
args: { to: tableName },
});
}
var columnTypeAssignments = collectTableAlterColumnTypeAssignments(rows);
rows.forEach(function (row) {
var name = row.name.trim();
@ -2621,6 +2642,12 @@ function tableAlterReadableValue(value) {
function tableAlterOperationSummary(operation) {
var args = operation.args || {};
if (operation.op === "rename_table") {
return {
text: "Rename table to " + tableAlterQuotedName(args.to) + ".",
damaging: false,
};
}
if (operation.op === "add_column") {
var addDetails = ["as " + args.type];
if (args.not_null) {
@ -2788,7 +2815,10 @@ function appendTableAlterReviewText(element, text) {
});
}
function tableAlterSetColumnTypeUrl() {
function tableAlterSetColumnTypeUrl(tableUrl) {
if (tableUrl) {
return tableUrl.replace(/\/$/, "") + "/-/set-column-type";
}
var data = tableAlterData();
if (!data || !data.path) {
return null;
@ -2798,11 +2828,11 @@ function tableAlterSetColumnTypeUrl() {
return url.toString();
}
async function assignTableAlterColumnTypes(assignments) {
async function assignTableAlterColumnTypes(assignments, tableUrl) {
if (!assignments.length) {
return;
}
var url = tableAlterSetColumnTypeUrl();
var url = tableAlterSetColumnTypeUrl(tableUrl);
if (!url) {
throw new Error("Could not find the set column type URL.");
}
@ -2841,6 +2871,16 @@ async function assignTableAlterColumnTypes(assignments) {
}
}
function tableAlterResultRenamesTable(result) {
return !!(
result &&
result.payload &&
(result.payload.operations || []).some(function (operation) {
return operation.op === "rename_table";
})
);
}
function showTableAlterEditor(state) {
state.mode = "edit";
state.reviewResult = null;
@ -2923,6 +2963,7 @@ async function applyTableAlterChanges(state, result) {
}
setTableAlterDialogSaving(state, true);
try {
var responseData = null;
if (result.payload) {
var response = await fetch(data.path, {
method: "POST",
@ -2932,7 +2973,6 @@ async function applyTableAlterChanges(state, result) {
},
body: JSON.stringify(result.payload),
});
var responseData = null;
try {
responseData = await response.json();
} catch (_error) {
@ -2942,10 +2982,18 @@ async function applyTableAlterChanges(state, result) {
throw rowMutationRequestError(response, responseData);
}
}
await assignTableAlterColumnTypes(result.columnTypeAssignments || []);
var tableUrl = responseData && responseData.table_url;
await assignTableAlterColumnTypes(
result.columnTypeAssignments || [],
tableUrl,
);
state.shouldRestoreFocus = false;
state.dialog.close();
location.reload();
if (tableAlterResultRenamesTable(result) && tableUrl) {
window.location.href = tableUrl;
} else {
location.reload();
}
} catch (error) {
setTableAlterDialogSaving(state, false);
showTableAlterDialogError(state, error.message || "Could not alter table");
@ -3088,6 +3136,13 @@ function ensureTableAlterDialog(manager) {
<div class="table-alter-column-list"></div>
<button type="button" class="table-alter-add-column"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg><span>Add column</span></button>
</div>
<details class="table-alter-table-options">
<summary>Rename table</summary>
<div class="table-alter-table-name-field">
<label class="table-alter-detail-label" for="table-alter-table-name">New table name</label>
<input id="table-alter-table-name" class="table-alter-input table-alter-table-name" type="text" autocomplete="off" placeholder="table name" required>
</div>
</details>
</div>
<div class="table-alter-review" hidden></div>
<div class="modal-footer">
@ -3106,6 +3161,8 @@ function ensureTableAlterDialog(manager) {
title: dialog.querySelector(".modal-title"),
error: dialog.querySelector(".table-alter-error"),
fields: dialog.querySelector(".table-alter-fields"),
tableOptions: dialog.querySelector(".table-alter-table-options"),
tableNameInput: dialog.querySelector(".table-alter-table-name"),
review: dialog.querySelector(".table-alter-review"),
columnList: dialog.querySelector(".table-alter-column-list"),
addColumnButton: dialog.querySelector(".table-alter-add-column"),
@ -3117,6 +3174,7 @@ function ensureTableAlterDialog(manager) {
shouldRestoreFocus: true,
isSaving: false,
initialSignature: "",
originalTableName: "",
nextColumnIndex: 0,
deletedColumns: [],
originalColumnNames: [],
@ -3134,6 +3192,11 @@ function ensureTableAlterDialog(manager) {
saveTableAlterDialog(tableAlterDialogState);
});
tableAlterDialogState.tableNameInput.addEventListener("input", function () {
clearTableAlterDialogError(tableAlterDialogState);
updateTableAlterSaveButtonState(tableAlterDialogState);
});
tableAlterDialogState.addColumnButton.addEventListener("click", function () {
if (tableAlterDialogState.isSaving) {
return;

View file

@ -561,6 +561,36 @@ def test_alter_table_review_rename_primary_key_column(page, datasette_server):
]
@pytest.mark.playwright
def test_alter_table_review_rename_table(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")
rename_details = dialog.locator(".table-alter-table-options")
assert rename_details.locator("summary").inner_text() == "Rename table"
assert not dialog.locator(".table-alter-table-name").is_visible()
assert save.is_disabled()
rename_details.locator("summary").click()
table_name = dialog.locator(".table-alter-table-name")
assert table_name.input_value() == "projects"
assert table_name.get_attribute("placeholder") == "table name"
table_name.fill("projects_archive")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Rename table to projects_archive." in review.inner_text()
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"projects_archive",
]
@pytest.mark.playwright
def test_alter_table_review_not_null_wording(page, datasette_server):
page.goto(f"{datasette_server}data/projects")