From 2d3c85dfc0b802dcb6736f17cb15e84f67d4f921 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 16 Jun 2026 18:02:58 -0700 Subject: [PATCH] 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. --- datasette/static/app.css | 323 ++++++++++++++ datasette/static/edit-tools.js | 679 ++++++++++++++++++++++++++++++ datasette/templates/database.html | 4 + datasette/views/database.py | 120 +++++- tests/test_playwright.py | 53 +++ tests/test_table_html.py | 144 +++++++ 6 files changed, 1303 insertions(+), 20 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 5fe4502d..ce0c46a8 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -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; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 8142d02b..4b93c4fd 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -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 = + ''; + + 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 = ` + +
+ +
+
+ + +
+
+
Columns
+
+ +
+
+ +
+ `; + 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); diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 371f6a22..23eeb571 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -6,6 +6,10 @@ {{- super() -}} {% include "_codemirror.html" %} {% include "_sql_parameter_styles.html" %} +{% if database_page_data.createTable %} + + +{% endif %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index cd9565c6..db70b135 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -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]+$") diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a8c5aa4b..0c7042f0 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -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) diff --git a/tests/test_table_html.py b/tests/test_table_html.py index aa67bb3f..887fdf50 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -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(