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

@ -1749,6 +1749,289 @@ datasette-autocomplete input[type="text"],
cursor: not-allowed;
}
dialog.table-create-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(760px, 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-create-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.table-create-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-create-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-create-dialog .modal-title {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.table-create-form {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.table-create-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-create-error:focus {
outline: 3px solid rgba(185, 28, 28, 0.18);
outline-offset: 2px;
}
.table-create-fields {
display: grid;
gap: 18px;
padding: 16px 24px 24px;
overflow-y: auto;
}
.table-create-field {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.table-create-label,
.table-create-columns-heading {
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.table-create-label {
padding-top: 8px;
}
.table-create-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-create-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-create-input-placeholder {
color: var(--muted);
}
.table-create-custom-column-type option {
color: var(--ink);
}
.table-create-custom-column-type option[value=""] {
color: var(--muted);
}
.table-create-table-name {
width: 100%;
}
.table-create-input:focus {
border-color: var(--accent);
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.table-create-columns {
display: grid;
gap: 10px;
}
.table-create-columns-heading {
font-weight: 600;
}
.table-create-column-list {
display: grid;
gap: 8px;
}
.table-create-column-row {
display: grid;
grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) minmax(3.5rem, max-content) 32px;
align-items: center;
gap: 8px;
min-width: 0;
position: relative;
}
.table-create-primary-key {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink);
font-size: 0.85rem;
min-width: 0;
white-space: nowrap;
justify-self: center;
}
.table-create-primary-key-input {
margin: 0;
}
.table-create-remove-column {
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-create-remove-column:hover,
.table-create-remove-column:focus {
background: rgba(74, 85, 104, 0.07);
}
.table-create-remove-column:focus {
outline: 3px solid #b3d4ff;
outline-offset: 1px;
}
.table-create-remove-column svg {
display: block;
}
.table-create-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-create-add-column:hover,
.table-create-add-column:focus {
background: #f8fafc;
}
.table-create-add-column:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 1px;
}
.table-create-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-create-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-create-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.table-create-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.table-create-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.table-create-dialog .btn-primary:hover {
background: #1949b8;
}
.table-create-dialog .btn:disabled,
.table-create-add-column:disabled,
.table-create-remove-column:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.row-link-with-actions {
display: inline-flex;
align-items: center;
@ -1892,6 +2175,46 @@ datasette-autocomplete input[type="text"],
padding-right: 18px;
}
dialog.table-create-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.table-create-dialog .modal-header,
.table-create-fields {
padding-left: 18px;
padding-right: 18px;
}
.table-create-error {
margin-left: 18px;
margin-right: 18px;
}
.table-create-field {
grid-template-columns: 1fr;
gap: 5px;
}
.table-create-label {
padding-top: 0;
}
.table-create-column-row {
grid-template-columns: minmax(0, 1fr) 8.5rem 3.5rem 32px;
align-items: end;
}
.table-create-custom-column-type {
grid-column: 1 / -1;
}
.table-create-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
.row-inline-action {
min-height: 30px;
min-width: 30px;

View file

@ -2,6 +2,8 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog";
var rowDeleteDialogState = null;
var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
var rowEditDialogState = null;
var TABLE_CREATE_DIALOG_ID = "table-create-dialog";
var tableCreateDialogState = null;
function ensureRowMutationStatus(manager) {
var status = document.querySelector(".row-mutation-status");
@ -43,6 +45,682 @@ function hideRowMutationStatus() {
status.textContent = "";
}
function databaseCreateTableData() {
return (
window._datasetteDatabaseData &&
window._datasetteDatabaseData.createTable
);
}
function tableCreateColumnTypes() {
var data = databaseCreateTableData() || {};
return data.columnTypes && data.columnTypes.length
? data.columnTypes
: ["text", "integer", "float", "blob"];
}
function tableCreateCustomColumnTypes() {
var data = databaseCreateTableData() || {};
return data.customColumnTypes || [];
}
function tableCreateCustomColumnType(name) {
var options = tableCreateCustomColumnTypes();
for (var i = 0; i < options.length; i += 1) {
if (options[i].name === name) {
return options[i];
}
}
return null;
}
function tableCreateCustomTypeAppliesToSqliteType(option, sqliteType) {
return (
option &&
option.sqliteTypes &&
option.sqliteTypes.indexOf(sqliteType) !== -1
);
}
function tableCreateDialogSignature(state) {
if (!state || !state.form) {
return "";
}
var columns = [];
state.columnList
.querySelectorAll(".table-create-column-row")
.forEach(function (row) {
columns.push({
name: row.querySelector(".table-create-column-name").value,
type: row.querySelector(".table-create-column-type").value,
customType:
(
row.querySelector(".table-create-custom-column-type") || {
value: "",
}
).value || "",
pk: row.querySelector(".table-create-primary-key-input").checked,
});
});
return JSON.stringify({
table: state.tableName.value,
columns: columns,
});
}
function tableCreateDialogHasChanges(state) {
return (
!!state &&
!state.isSaving &&
tableCreateDialogSignature(state) !== state.initialSignature
);
}
function clearTableCreateDialogError(state) {
state.error.hidden = true;
state.error.textContent = "";
state.dialog.removeAttribute("aria-describedby");
}
function showTableCreateDialogError(state, message) {
state.error.hidden = false;
state.error.textContent = message;
state.dialog.setAttribute("aria-describedby", "table-create-error");
state.error.focus();
}
function setTableCreateDialogSaving(state, isSaving) {
state.isSaving = isSaving;
state.cancelButton.disabled = isSaving;
state.saveButton.disabled = isSaving;
state.addColumnButton.disabled = isSaving;
state.saveButton.textContent = isSaving ? "Creating..." : "Create table";
state.columnList
.querySelectorAll("input, select, button")
.forEach(function (control) {
control.disabled = isSaving;
});
}
function tableCreateSelectTypeValue(select, type) {
var options = tableCreateColumnTypes();
options.forEach(function (option) {
var optionElement = document.createElement("option");
optionElement.value = option;
optionElement.textContent = option;
select.appendChild(optionElement);
});
select.value = options.indexOf(type) === -1 ? options[0] : type;
}
function updateTableCreateCustomColumnTypePlaceholder(select) {
select.classList.toggle(
"table-create-input-placeholder",
!select.value,
);
}
function createTableCustomColumnTypeSelect() {
var options = tableCreateCustomColumnTypes();
var select = document.createElement("select");
select.className = "table-create-input table-create-custom-column-type";
select.setAttribute("aria-label", "Custom column type");
var blankOption = document.createElement("option");
blankOption.value = "";
blankOption.textContent = "- custom type -";
select.appendChild(blankOption);
options.forEach(function (option) {
var optionElement = document.createElement("option");
optionElement.value = option.name;
optionElement.textContent = option.description
? option.description + " (" + option.name + ")"
: option.name;
select.appendChild(optionElement);
});
updateTableCreateCustomColumnTypePlaceholder(select);
return select;
}
function syncTableCreateCustomTypeForSqliteType(row) {
var typeSelect = row.querySelector(".table-create-column-type");
var customTypeSelect = row.querySelector(".table-create-custom-column-type");
if (!typeSelect || !customTypeSelect || !customTypeSelect.value) {
return;
}
var option = tableCreateCustomColumnType(customTypeSelect.value);
if (!tableCreateCustomTypeAppliesToSqliteType(option, typeSelect.value)) {
customTypeSelect.value = "";
updateTableCreateCustomColumnTypePlaceholder(customTypeSelect);
}
}
function createTableColumnRow(state, column) {
var index = state.nextColumnIndex;
state.nextColumnIndex += 1;
var row = document.createElement("div");
row.className = "table-create-column-row";
var nameId = "table-create-column-name-" + index;
var nameLabel = document.createElement("label");
nameLabel.className = "table-create-column-label";
nameLabel.setAttribute("for", nameId);
nameLabel.textContent = "Column";
var nameInput = document.createElement("input");
nameInput.id = nameId;
nameInput.className = "table-create-input table-create-column-name";
nameInput.type = "text";
nameInput.required = true;
nameInput.autocomplete = "off";
nameInput.value = column && column.name ? column.name : "";
var typeSelect = document.createElement("select");
typeSelect.className = "table-create-input table-create-column-type";
typeSelect.setAttribute("aria-label", "Column type");
tableCreateSelectTypeValue(typeSelect, column && column.type);
var customTypeSelect = createTableCustomColumnTypeSelect();
if (column && column.customType) {
customTypeSelect.value = column.customType;
}
updateTableCreateCustomColumnTypePlaceholder(customTypeSelect);
var pkLabel = document.createElement("label");
pkLabel.className = "table-create-primary-key";
var pkInput = document.createElement("input");
pkInput.type = "checkbox";
pkInput.className = "table-create-primary-key-input";
pkInput.checked = !!(column && column.primaryKey);
var pkText = document.createElement("span");
pkText.textContent = "PK";
pkText.title = "Primary key";
pkLabel.appendChild(pkInput);
pkLabel.appendChild(pkText);
var removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "table-create-remove-column";
removeButton.setAttribute("aria-label", "Remove column");
removeButton.title = "Remove column";
removeButton.innerHTML =
'<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="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path></svg>';
row.appendChild(nameLabel);
row.appendChild(nameInput);
row.appendChild(typeSelect);
if (tableCreateCustomColumnTypes().length) {
row.appendChild(customTypeSelect);
}
row.appendChild(pkLabel);
row.appendChild(removeButton);
removeButton.addEventListener("click", function () {
if (state.isSaving) {
return;
}
row.remove();
clearTableCreateDialogError(state);
var nextInput = state.columnList.querySelector(
".table-create-column-name",
);
if (nextInput) {
nextInput.focus();
} else {
state.addColumnButton.focus();
}
});
nameInput.addEventListener("input", function () {
clearTableCreateDialogError(state);
});
typeSelect.addEventListener("change", function () {
clearTableCreateDialogError(state);
syncTableCreateCustomTypeForSqliteType(row);
});
customTypeSelect.addEventListener("change", function () {
clearTableCreateDialogError(state);
updateTableCreateCustomColumnTypePlaceholder(customTypeSelect);
var option = tableCreateCustomColumnType(customTypeSelect.value);
if (
option &&
option.fixedSqliteType &&
tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1
) {
typeSelect.value = option.fixedSqliteType;
}
});
pkInput.addEventListener("change", function () {
clearTableCreateDialogError(state);
});
return row;
}
function addTableCreateColumn(state, column) {
var row = createTableColumnRow(state, column || { type: "text" });
state.columnList.appendChild(row);
return row;
}
function resetTableCreateDialog(state) {
state.nextColumnIndex = 0;
state.tableName.value = "";
state.columnList.textContent = "";
addTableCreateColumn(state, {
name: "id",
type: "integer",
primaryKey: true,
});
addTableCreateColumn(state, {
name: "",
type: "text",
primaryKey: false,
});
state.initialSignature = tableCreateDialogSignature(state);
}
function collectTableCreatePayload(state) {
var payload = {
table: state.tableName.value.trim(),
columns: [],
};
var primaryKeys = [];
state.columnList
.querySelectorAll(".table-create-column-row")
.forEach(function (row) {
var name = row.querySelector(".table-create-column-name").value.trim();
var type = row.querySelector(".table-create-column-type").value;
payload.columns.push({ name: name, type: type });
if (row.querySelector(".table-create-primary-key-input").checked) {
primaryKeys.push(name);
}
});
if (primaryKeys.length === 1) {
payload.pk = primaryKeys[0];
} else if (primaryKeys.length > 1) {
payload.pks = primaryKeys;
}
return payload;
}
function collectTableCreateColumnTypeAssignments(state) {
var assignments = [];
state.columnList
.querySelectorAll(".table-create-column-row")
.forEach(function (row) {
var customTypeSelect = row.querySelector(
".table-create-custom-column-type",
);
if (!customTypeSelect || !customTypeSelect.value) {
return;
}
assignments.push({
column: row.querySelector(".table-create-column-name").value.trim(),
columnType: customTypeSelect.value,
sqliteType: row.querySelector(".table-create-column-type").value,
});
});
return assignments;
}
function validateTableCreatePayload(payload) {
if (!payload.table) {
return "Table name is required.";
}
if (payload.table.indexOf("\n") !== -1) {
return "Table name cannot contain newlines.";
}
if (/^sqlite_/i.test(payload.table)) {
return "Table name cannot start with sqlite_.";
}
if (!payload.columns.length) {
return "At least one column is required.";
}
var seen = {};
var supportedTypes = tableCreateColumnTypes();
for (var i = 0; i < payload.columns.length; i += 1) {
var column = payload.columns[i];
if (!column.name) {
return "Column name is required.";
}
if (column.name.indexOf("\n") !== -1) {
return "Column names cannot contain newlines.";
}
var columnKey = column.name.toLowerCase();
if (seen[columnKey]) {
return "Duplicate column name: " + column.name;
}
seen[columnKey] = true;
if (supportedTypes.indexOf(column.type) === -1) {
return "Unsupported column type: " + column.type;
}
}
return null;
}
function validateTableCreateColumnTypeAssignments(assignments) {
for (var i = 0; i < assignments.length; i += 1) {
var assignment = assignments[i];
var option = tableCreateCustomColumnType(assignment.columnType);
if (!option) {
return "Unknown custom column type: " + assignment.columnType;
}
if (!tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType)) {
return (
"Custom type " +
assignment.columnType +
" cannot be used with SQLite type " +
assignment.sqliteType +
"."
);
}
}
return null;
}
function fallbackTableUrl(tableName) {
var data = databaseCreateTableData() || {};
if (!data.path) {
return null;
}
return data.path.replace(/\/-\/create$/, "/" + encodeURIComponent(tableName));
}
function tableCreateSetColumnTypeUrl(responseData, payload) {
var tableUrl =
responseData.table_url || fallbackTableUrl(responseData.table || payload.table);
if (!tableUrl) {
return null;
}
var url = new URL(tableUrl, location.href);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/$/, "") + "/-/set-column-type";
return url.toString();
}
async function assignTableCreateColumnTypes(responseData, payload, assignments) {
if (!assignments.length) {
return;
}
var url = tableCreateSetColumnTypeUrl(responseData, payload);
if (!url) {
throw new Error("Could not find the set column type URL.");
}
for (var i = 0; i < assignments.length; i += 1) {
var assignment = assignments[i];
var response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
column: assignment.column,
column_type: {
type: assignment.columnType,
},
}),
});
var data = null;
try {
data = await response.json();
} catch (_error) {
data = null;
}
if (!response.ok || (data && data.ok === false)) {
var error = rowMutationRequestError(response, data);
throw new Error(
"Created table, but could not set custom type for " +
assignment.column +
": " +
error.message,
);
}
}
}
async function saveTableCreateDialog(state) {
if (state.isSaving) {
return;
}
var data = databaseCreateTableData();
if (!data || !data.path) {
showTableCreateDialogError(state, "Could not find the create table URL.");
return;
}
clearTableCreateDialogError(state);
var payload = collectTableCreatePayload(state);
var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state);
var validationError = validateTableCreatePayload(payload);
if (validationError) {
showTableCreateDialogError(state, validationError);
return;
}
var columnTypeValidationError = validateTableCreateColumnTypeAssignments(
columnTypeAssignments,
);
if (columnTypeValidationError) {
showTableCreateDialogError(state, columnTypeValidationError);
return;
}
setTableCreateDialogSaving(state, true);
try {
var response = await fetch(data.path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var responseData = null;
try {
responseData = await response.json();
} catch (_error) {
responseData = null;
}
if (!response.ok || (responseData && responseData.ok === false)) {
throw rowMutationRequestError(response, responseData);
}
await assignTableCreateColumnTypes(
responseData,
payload,
columnTypeAssignments,
);
var tableUrl =
responseData.table_url || fallbackTableUrl(responseData.table || payload.table);
state.shouldRestoreFocus = false;
state.dialog.close();
if (tableUrl) {
location.href = tableUrl;
} else {
location.reload();
}
} catch (error) {
setTableCreateDialogSaving(state, false);
showTableCreateDialogError(
state,
error.message || "Could not create table",
);
}
}
function confirmDiscardTableCreateChanges(state) {
if (!tableCreateDialogHasChanges(state)) {
return true;
}
return window.confirm("Discard this new table?");
}
function closeTableCreateDialogIfConfirmed(state) {
if (!state || state.isSaving) {
return false;
}
if (!confirmDiscardTableCreateChanges(state)) {
return false;
}
state.shouldRestoreFocus = true;
state.dialog.close();
return true;
}
function ensureTableCreateDialog(manager) {
if (tableCreateDialogState) {
return tableCreateDialogState;
}
if (!window.HTMLDialogElement) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = TABLE_CREATE_DIALOG_ID;
dialog.className = "table-create-dialog";
dialog.setAttribute("aria-labelledby", "table-create-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="table-create-title">Create table</span>
</div>
<form class="table-create-form" method="post" novalidate>
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
<div class="table-create-fields">
<div class="table-create-field">
<label class="table-create-label" for="table-create-name">Table name</label>
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
</div>
<div class="table-create-columns">
<div class="table-create-columns-heading">Columns</div>
<div class="table-create-column-list"></div>
<button type="button" class="table-create-add-column">Add column</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
</div>
</form>
`;
document.body.appendChild(dialog);
tableCreateDialogState = {
dialog: dialog,
form: dialog.querySelector(".table-create-form"),
title: dialog.querySelector(".modal-title"),
error: dialog.querySelector(".table-create-error"),
fields: dialog.querySelector(".table-create-fields"),
tableName: dialog.querySelector(".table-create-table-name"),
columnList: dialog.querySelector(".table-create-column-list"),
addColumnButton: dialog.querySelector(".table-create-add-column"),
cancelButton: dialog.querySelector(".table-create-cancel"),
saveButton: dialog.querySelector(".table-create-save"),
currentButton: null,
shouldRestoreFocus: true,
isSaving: false,
initialSignature: "",
nextColumnIndex: 0,
manager: manager,
};
tableCreateDialogState.form.addEventListener("submit", function (ev) {
ev.preventDefault();
saveTableCreateDialog(tableCreateDialogState);
});
tableCreateDialogState.addColumnButton.addEventListener("click", function () {
if (tableCreateDialogState.isSaving) {
return;
}
var row = addTableCreateColumn(tableCreateDialogState, { type: "text" });
clearTableCreateDialogError(tableCreateDialogState);
row.querySelector(".table-create-column-name").focus();
});
tableCreateDialogState.cancelButton.addEventListener("click", function () {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
tableCreateDialogState.tableName.addEventListener("input", function () {
clearTableCreateDialogError(tableCreateDialogState);
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
}
});
dialog.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
dialog.addEventListener("close", function () {
var state = tableCreateDialogState;
clearTableCreateDialogError(state);
setTableCreateDialogSaving(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
return tableCreateDialogState;
}
function openTableCreateDialog(button, manager) {
var data = databaseCreateTableData();
if (!data) {
return;
}
var state = ensureTableCreateDialog(manager);
if (!state) {
return;
}
var menu = button.closest("details");
if (menu) {
menu.open = false;
}
state.manager = manager;
state.currentButton = button;
state.shouldRestoreFocus = true;
state.title.textContent = "Create a table in " + data.databaseName;
clearTableCreateDialogError(state);
resetTableCreateDialog(state);
if (!state.dialog.open) {
state.dialog.showModal();
}
state.tableName.focus();
}
function initTableCreateActions(manager) {
if (!window.fetch || !window.HTMLDialogElement || !databaseCreateTableData()) {
return;
}
document.addEventListener("click", function (ev) {
var button = ev.target.closest(
'button[data-database-action="create-table"]',
);
if (!button) {
return;
}
ev.preventDefault();
openTableCreateDialog(button, manager);
});
}
function setRowDeleteDialogBusy(state, isBusy) {
state.isBusy = isBusy;
state.confirmButton.disabled = isBusy;
@ -2017,6 +2695,7 @@ document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt;
registerBuiltinColumnFieldPlugins(manager);
initTableCreateActions(manager);
initRowInsertActions(manager);
initRowEditActions(manager);
initRowDeleteActions(manager);

View file

@ -6,6 +6,10 @@
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}

View file

@ -13,7 +13,8 @@ import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.column_types import SQLiteType
from datasette.resources import DatabaseResource, QueryResource, TableResource
from datasette.stored_queries import stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
@ -46,6 +47,18 @@ from .table_extras import (
)
from . import Context
CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
CREATE_TABLE_SQLITE_TYPES = {
"text": SQLiteType.TEXT,
"integer": SQLiteType.INTEGER,
"float": SQLiteType.REAL,
"blob": SQLiteType.BLOB,
}
CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = {
sqlite_type: column_type
for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items()
}
class DatabaseView(View):
async def get(self, request, datasette):
@ -117,21 +130,36 @@ class DatabaseView(View):
else len(stored_queries)
)
# Resolve the registered database-level actions for this database in
# one batched query, seeding the request permission cache so allowed()
# calls made inside plugin hooks below are served from the cache.
database_action_permissions = await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
create_table_ui = await _database_create_table_ui(
datasette, request, db, database, database_action_permissions
)
async def database_actions():
# Resolve the registered database-level actions for this
# database in one batched query, seeding the request permission
# cache so that allowed() calls made inside the plugin hooks
# below are served from the cache
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
links = []
if create_table_ui:
links.append(
{
"type": "button",
"label": "Create table",
"description": "Create a new table in this database.",
"attrs": {
"aria-label": "Create table in {}".format(database),
"data-database-action": "create-table",
},
}
)
for hook in pm.hook.database_actions(
datasette=datasette,
database=database,
@ -211,6 +239,9 @@ class DatabaseView(View):
),
metadata=metadata,
database_color=db.color,
database_page_data=(
{"createTable": create_table_ui} if create_table_ui else {}
),
database_actions=database_actions,
show_hidden=request.args.get("_show_hidden"),
editable=True,
@ -263,6 +294,9 @@ class DatabaseContext(Context):
)
metadata: dict = field(metadata={"help": "Metadata for the database"})
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the database page"}
)
database_actions: callable = field(
metadata={
"help": "Callable returning list of action links for the database menu"
@ -292,6 +326,57 @@ class DatabaseContext(Context):
)
async def _database_create_table_ui(
datasette, request, db, database_name, database_action_permissions
):
if not db.is_mutable:
return None
if not database_action_permissions.get("create-table"):
return None
data = {
"path": "{}/-/create".format(datasette.urls.database(database_name)),
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table="__new_table__"),
actor=request.actor,
)
if can_set_column_type:
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
datasette
)
return data
def _custom_column_type_options_for_create_table(datasette):
options = []
for name, ct_cls in sorted(datasette._column_types.items()):
sqlite_types = getattr(ct_cls, "sqlite_types", None)
if sqlite_types is None:
option_sqlite_types = CREATE_TABLE_COLUMN_TYPES[:]
else:
option_sqlite_types = [
create_table_type
for create_table_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items()
if sqlite_type in sqlite_types
]
if not option_sqlite_types:
continue
option = {
"name": name,
"description": ct_cls.description,
"sqliteTypes": option_sqlite_types,
}
if sqlite_types is not None and len(sqlite_types) == 1:
fixed_sqlite_type = CREATE_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_types[0])
if fixed_sqlite_type is not None:
option["fixedSqliteType"] = fixed_sqlite_type
options.append(option)
return options
@dataclass
class QueryContext(Context):
database: str = field(metadata={"help": "The name of the database being queried"})
@ -1069,12 +1154,7 @@ class TableCreateView(BaseView):
"replace",
"alter",
}
_supported_column_types = {
"text",
"integer",
"float",
"blob",
}
_supported_column_types = set(CREATE_TABLE_COLUMN_TYPES)
# Any string that does not contain a newline or start with sqlite_
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")

View file

@ -117,6 +117,10 @@ def write_playwright_config(config_path):
{
"databases": {
"data": {
"permissions": {
"create-table": True,
"set-column-type": True,
},
"tables": {
"projects": {
"label_column": "title",
@ -275,6 +279,55 @@ def test_datasette_homepage_contains_datasette(page, datasette_server):
assert "Datasette" in page.locator("body").inner_text()
@pytest.mark.playwright
def test_create_table_flow(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()
assert dialog.locator(".modal-title").inner_text() == "Create a table in data"
placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0)
assert placeholder_select.input_value() == ""
assert (
placeholder_select.locator("option:checked").inner_text() == "- custom type -"
)
assert "table-create-input-placeholder" in placeholder_select.get_attribute("class")
dialog.locator('input[name="table"]').fill("playwright_created")
dialog.locator(".table-create-column-name").nth(1).fill("title")
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")
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(3).fill("metadata")
dialog.locator(".table-create-column-type").nth(3).select_option("integer")
dialog.locator(".table-create-custom-column-type").nth(3).select_option("json")
assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text"
assert "table-create-input-placeholder" not in dialog.locator(
".table-create-custom-column-type"
).nth(3).get_attribute("class")
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_created")
assert "playwright_created" in page.locator("h1").inner_text()
response = httpx.get(
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
)
response.raise_for_status()
data = response.json()
assert data["columns"] == [
"id",
"title",
"score",
"metadata",
]
assert data["column_types"] == {
"metadata": {"type": "json", "config": None},
}
@pytest.mark.playwright
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
page.goto(datasette_server)

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(