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(