diff --git a/datasette/default_table_actions.py b/datasette/default_table_actions.py new file mode 100644 index 00000000..e41434ef --- /dev/null +++ b/datasette/default_table_actions.py @@ -0,0 +1,29 @@ +from datasette import hookimpl +from datasette.resources import TableResource + + +@hookimpl +def table_actions(datasette, actor, database, table, request): + async def inner(): + db = datasette.get_database(database) + if not db.is_mutable: + return [] + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database, table=table), + actor=actor, + ): + return [] + return [ + { + "type": "button", + "label": "Alter table", + "description": "Change columns and primary key for this table.", + "attrs": { + "aria-label": "Alter table {}".format(table), + "data-table-action": "alter-table", + }, + } + ] + + return inner diff --git a/datasette/plugins.py b/datasette/plugins.py index f0fbc7f8..ae2cb17d 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -31,6 +31,7 @@ DEFAULT_PLUGINS = ( "datasette.default_debug_menu", "datasette.default_jump_items", "datasette.default_database_actions", + "datasette.default_table_actions", "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", diff --git a/datasette/static/app.css b/datasette/static/app.css index ce0c46a8..f9ebe5ac 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2032,6 +2032,505 @@ dialog.table-create-dialog::backdrop { cursor: not-allowed; } +dialog.table-alter-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-alter-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-alter-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-alter-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-alter-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-alter-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-alter-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-alter-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-alter-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-alter-fields[hidden], +.table-alter-dialog .modal-footer [hidden] { + display: none; +} + +.table-alter-review { + display: grid; + gap: 12px; + overflow-y: auto; + padding: 16px 24px 24px; +} + +.table-alter-review[hidden] { + display: none; +} + +.table-alter-review-title { + color: var(--ink); + font-size: 1rem; + line-height: 1.35; + margin: 0; +} + +.table-alter-review-title:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-review-intro { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +.table-alter-review-warning { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 0; + padding: 10px 12px; +} + +.table-alter-review-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 1.4rem; +} + +.table-alter-review-list li { + color: var(--ink); + line-height: 1.4; +} + +.table-alter-review-damaging { + font-weight: 600; +} + +.table-alter-review-name { + background: #eef6ff; + border: 1px solid #c9ddf2; + border-radius: 4px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 1px 4px; + white-space: nowrap; +} + +.table-alter-columns { + display: grid; + gap: 10px; +} + +.table-alter-column-list { + display: grid; + gap: 8px; +} + +.table-alter-column-headings, +.table-alter-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-alter-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-alter-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-alter-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-alter-input { + box-sizing: border-box; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +.table-alter-input-placeholder { + color: var(--muted); +} + +.table-alter-default-expr option, +.table-alter-custom-column-type option { + color: var(--ink); +} + +.table-alter-default-expr option[value=""], +.table-alter-custom-column-type option[value=""] { + color: var(--muted); +} + +.table-alter-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-alter-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-alter-column-details[hidden] { + display: none; +} + +.table-alter-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-alter-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-alter-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-alter-not-null, +.table-alter-primary-key { + grid-column: 1 / -1; +} + +.table-alter-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-alter-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-alter-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-alter-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-alter-more-options:hover, +.table-alter-more-options:focus { + text-decoration: underline; +} + +.table-alter-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-alter-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-alter-icon-button:hover, +.table-alter-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-alter-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-alter-icon-button svg { + display: block; +} + +.table-alter-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-alter-add-column:hover, +.table-alter-add-column:focus { + background: #f8fafc; +} + +.table-alter-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-alter-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-alter-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-alter-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-alter-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-alter-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-alter-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-alter-dialog .btn-primary:disabled, +.table-alter-dialog .btn-primary:disabled:hover { + background: #a0aec0; + color: #fff; +} + +.table-alter-dialog .btn:disabled, +.table-alter-add-column:disabled, +.table-alter-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +@media (max-width: 900px) { + dialog.table-alter-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-alter-dialog .modal-header, + .table-alter-fields, + .table-alter-review { + padding-left: 18px; + padding-right: 18px; + } + + .table-alter-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-alter-column-headings { + display: none; + } + + .table-alter-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-alter-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-alter-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-alter-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-alter-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-alter-custom-column-type { + grid-column: 1 / 3; + grid-row: 2; + } + + .table-alter-move-controls { + grid-column: 1; + grid-row: 3; + justify-self: start; + } + + .table-alter-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 3; + } + + .table-alter-column-details { + grid-template-columns: 1fr; + } + + .table-alter-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } +} + .row-link-with-actions { display: inline-flex; align-items: center; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 4b93c4fd..284d6bde 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -4,6 +4,8 @@ var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; var tableCreateDialogState = null; +var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; +var tableAlterDialogState = null; function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); @@ -59,6 +61,16 @@ function tableCreateColumnTypes() { : ["text", "integer", "float", "blob"]; } +function sqliteColumnTypeLabel(type) { + if (type === "float") { + return "floating point number"; + } + if (type === "blob") { + return "blob - binary data"; + } + return type; +} + function tableCreateCustomColumnTypes() { var data = databaseCreateTableData() || {}; return data.customColumnTypes || []; @@ -147,7 +159,7 @@ function tableCreateSelectTypeValue(select, type) { options.forEach(function (option) { var optionElement = document.createElement("option"); optionElement.value = option; - optionElement.textContent = option; + optionElement.textContent = sqliteColumnTypeLabel(option); select.appendChild(optionElement); }); select.value = options.indexOf(type) === -1 ? options[0] : type; @@ -819,6 +831,1469 @@ function tableInsertData() { return tablePageData().insertRow; } +function tableAlterData() { + return tablePageData().alterTable; +} + +function tableAlterColumnTypes() { + var data = tableAlterData() || {}; + return data.columnTypes && data.columnTypes.length + ? data.columnTypes + : ["text", "integer", "float", "blob"]; +} + +function tableAlterDefaultExpressions() { + var data = tableAlterData() || {}; + return data.defaultExpressions || []; +} + +function tableAlterCustomColumnTypes() { + var data = tableAlterData() || {}; + return data.customColumnTypes || []; +} + +function tableAlterCustomColumnType(name) { + var options = tableAlterCustomColumnTypes(); + for (var i = 0; i < options.length; i += 1) { + if (options[i].name === name) { + return options[i]; + } + } + return null; +} + +function tableAlterCustomTypeAppliesToSqliteType(option, sqliteType) { + return ( + option && + option.sqliteTypes && + option.sqliteTypes.indexOf(sqliteType) !== -1 + ); +} + +function tableAlterDialogRows(state) { + return Array.prototype.slice.call( + state.columnList.querySelectorAll(".table-alter-column-row"), + ); +} + +function tableAlterRowSignature(row) { + return { + existing: row.dataset.existing === "1", + originalName: row.dataset.originalName || "", + name: row.querySelector(".table-alter-column-name").value, + type: row.querySelector(".table-alter-column-type").value, + customType: + ( + row.querySelector(".table-alter-custom-column-type") || { + value: "", + } + ).value || "", + notNull: row.querySelector(".table-alter-not-null-input").checked, + defaultValue: row.querySelector(".table-alter-default").value, + defaultExpr: row.querySelector(".table-alter-default-expr").value, + pk: row.querySelector(".table-alter-primary-key-input").checked, + }; +} + +function tableAlterDialogSignature(state) { + if (!state || !state.form) { + return ""; + } + return JSON.stringify({ + columns: tableAlterDialogRows(state).map(tableAlterRowSignature), + deletedColumns: state.deletedColumns.slice(), + }); +} + +function tableAlterDialogHasChanges(state) { + return ( + !!state && + !state.isSaving && + tableAlterDialogSignature(state) !== state.initialSignature + ); +} + +function updateTableAlterSaveButtonState(state) { + if (!state || !state.saveButton) { + return; + } + state.saveButton.disabled = + state.isSaving || + (state.mode !== "review" && + tableAlterDialogSignature(state) === state.initialSignature); +} + +function tableAlterRowIsPrimaryKey(row) { + var input = row && row.querySelector(".table-alter-primary-key-input"); + return !!(input && input.checked); +} + +function tableAlterFirstNonPrimaryRow(state) { + var rows = tableAlterDialogRows(state); + for (var i = 0; i < rows.length; i += 1) { + if (!tableAlterRowIsPrimaryKey(rows[i])) { + return rows[i]; + } + } + return null; +} + +function updateTableAlterMoveButtons(state) { + if (!state || !state.columnList) { + return; + } + var firstNonPrimary = tableAlterFirstNonPrimaryRow(state); + var rows = tableAlterDialogRows(state); + var hasPrimaryKeys = rows.some(function (row) { + return tableAlterRowIsPrimaryKey(row); + }); + var primaryKeyMoveTitle = "Primary key columns are always listed first"; + rows.forEach(function (row) { + var isPrimaryKey = tableAlterRowIsPrimaryKey(row); + var previous = row.previousElementSibling; + var next = row.nextElementSibling; + row.querySelectorAll(".table-alter-move-controls button").forEach( + function (button) { + button.title = button.dataset.defaultTitle || button.title; + button.disabled = state.isSaving || isPrimaryKey; + if (isPrimaryKey) { + button.title = primaryKeyMoveTitle; + } + }, + ); + if (!isPrimaryKey) { + var topButton = row.querySelector(".table-alter-move-top"); + var upButton = row.querySelector(".table-alter-move-up"); + var downButton = row.querySelector(".table-alter-move-down"); + var bottomButton = row.querySelector(".table-alter-move-bottom"); + topButton.disabled = + state.isSaving || !firstNonPrimary || row === firstNonPrimary; + upButton.disabled = + state.isSaving || !previous || tableAlterRowIsPrimaryKey(previous); + downButton.disabled = state.isSaving || !next; + bottomButton.disabled = state.isSaving || !next; + if (hasPrimaryKeys && row === firstNonPrimary) { + topButton.title = primaryKeyMoveTitle; + upButton.title = primaryKeyMoveTitle; + } + } + }); +} + +function normalizeTableAlterPrimaryKeyRows(state) { + var rows = tableAlterDialogRows(state); + rows + .filter(function (row) { + return tableAlterRowIsPrimaryKey(row); + }) + .concat( + rows.filter(function (row) { + return !tableAlterRowIsPrimaryKey(row); + }), + ) + .forEach(function (row) { + state.columnList.appendChild(row); + }); +} + +function clearTableAlterDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableAlterDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-alter-error"); + state.error.focus(); +} + +function setTableAlterDialogSaving(state, isSaving) { + state.isSaving = isSaving; + state.cancelButton.disabled = isSaving; + state.addColumnButton.disabled = isSaving; + state.backButton.disabled = isSaving; + state.saveButton.textContent = isSaving + ? state.mode === "review" + ? "Applying..." + : "Preparing..." + : tableAlterSaveButtonText(state); + state.columnList + .querySelectorAll("input, select, button") + .forEach(function (control) { + control.disabled = isSaving; + }); + if (!isSaving) { + state.columnList + .querySelectorAll(".table-alter-default-expr") + .forEach(function (select) { + syncTableAlterDefaultControls(select.closest(".table-alter-column-row")); + }); + } + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function tableAlterSaveButtonText(state) { + return state && state.mode === "review" ? "Apply changes" : "Review changes"; +} + +function tableAlterSelectTypeValue(select, type) { + var options = tableAlterColumnTypes(); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = sqliteColumnTypeLabel(option); + select.appendChild(optionElement); + }); + select.value = options.indexOf(type) === -1 ? options[0] : type; +} + +function updateTableAlterCustomColumnTypePlaceholder(select) { + select.classList.toggle( + "table-alter-input-placeholder", + !select.value, + ); +} + +function createTableAlterCustomColumnTypeSelect() { + var options = tableAlterCustomColumnTypes(); + var select = document.createElement("select"); + select.className = "table-alter-input table-alter-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); + }); + updateTableAlterCustomColumnTypePlaceholder(select); + return select; +} + +function syncTableAlterCustomTypeForSqliteType(row) { + var typeSelect = row.querySelector(".table-alter-column-type"); + var customTypeSelect = row.querySelector(".table-alter-custom-column-type"); + if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { + return; + } + var option = tableAlterCustomColumnType(customTypeSelect.value); + if (!tableAlterCustomTypeAppliesToSqliteType(option, typeSelect.value)) { + customTypeSelect.value = ""; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + } +} + +function tableAlterSelectDefaultExprValue(select, value) { + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- default expr -"; + select.appendChild(blankOption); + tableAlterDefaultExpressions().forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = option.replace(/_/g, " "); + select.appendChild(optionElement); + }); + select.value = value || ""; + updateTableAlterDefaultExprPlaceholder(select); +} + +function updateTableAlterDefaultExprPlaceholder(select) { + select.classList.toggle("table-alter-input-placeholder", !select.value); +} + +function syncTableAlterDefaultControls(row) { + if (!row) { + return; + } + var defaultInput = row.querySelector(".table-alter-default"); + var defaultExprSelect = row.querySelector(".table-alter-default-expr"); + if (!defaultInput || !defaultExprSelect) { + return; + } + defaultInput.disabled = !!defaultExprSelect.value; + updateTableAlterDefaultExprPlaceholder(defaultExprSelect); +} + +function createTableAlterColumnRow(state, column) { + var index = state.nextColumnIndex; + state.nextColumnIndex += 1; + var existing = !!(column && column.existing); + var originalName = existing ? column.name || "" : ""; + var originalCustomType = + existing && column.column_type ? column.column_type.type || "" : ""; + var originalDefault = + existing && column.has_default && column.default !== null + ? String(column.default) + : ""; + + var row = document.createElement("div"); + row.className = "table-alter-column-row"; + row.dataset.existing = existing ? "1" : "0"; + row.dataset.originalName = originalName; + row.dataset.originalType = existing ? column.type || "text" : ""; + row.dataset.originalNotNull = existing && column.notnull ? "1" : "0"; + row.dataset.originalHasDefault = existing && column.has_default ? "1" : "0"; + row.dataset.originalDefault = originalDefault; + row.dataset.originalPk = existing && column.is_pk ? "1" : "0"; + row.dataset.originalCustomType = originalCustomType; + + var main = document.createElement("div"); + main.className = "table-alter-column-main"; + + var details = document.createElement("div"); + details.className = "table-alter-column-details"; + details.id = "table-alter-column-details-" + index; + details.hidden = !(column && column.expanded); + + var expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = "table-alter-more-options"; + expandButton.setAttribute("aria-label", "Toggle column settings"); + expandButton.setAttribute("aria-controls", details.id); + expandButton.setAttribute( + "aria-expanded", + details.hidden ? "false" : "true", + ); + function updateExpandButton() { + var isExpanded = expandButton.getAttribute("aria-expanded") === "true"; + expandButton.textContent = isExpanded + ? "v Hide options" + : "> Advanced options"; + expandButton.title = isExpanded + ? "Hide column settings" + : "Show column settings"; + } + updateExpandButton(); + + var nameId = "table-alter-column-name-" + index; + var nameLabel = document.createElement("label"); + nameLabel.className = "table-alter-column-label"; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Column"; + + var nameInput = document.createElement("input"); + nameInput.id = nameId; + nameInput.className = "table-alter-input table-alter-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-alter-input table-alter-column-type"; + typeSelect.setAttribute("aria-label", "Column type"); + tableAlterSelectTypeValue(typeSelect, column && column.type); + + var customTypeSelect = null; + if (tableAlterCustomColumnTypes().length) { + customTypeSelect = createTableAlterCustomColumnTypeSelect(); + customTypeSelect.value = originalCustomType; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + } + + var notNullLabel = document.createElement("label"); + notNullLabel.className = "table-alter-detail-check table-alter-not-null"; + var notNullInput = document.createElement("input"); + notNullInput.type = "checkbox"; + notNullInput.className = "table-alter-not-null-input"; + notNullInput.checked = !!(column && column.notnull); + var notNullText = document.createElement("span"); + var notNullStrong = document.createElement("strong"); + notNullStrong.textContent = "Not null"; + notNullText.appendChild(notNullStrong); + notNullText.appendChild( + document.createTextNode(" This value cannot be left unset"), + ); + notNullLabel.appendChild(notNullInput); + notNullLabel.appendChild(notNullText); + + var defaultId = "table-alter-column-default-" + index; + var defaultField = document.createElement("div"); + defaultField.className = "table-alter-detail-field"; + var defaultLabel = document.createElement("label"); + defaultLabel.className = "table-alter-detail-label"; + defaultLabel.setAttribute("for", defaultId); + defaultLabel.textContent = "Default value"; + var defaultInput = document.createElement("input"); + defaultInput.id = defaultId; + defaultInput.className = "table-alter-input table-alter-default"; + defaultInput.type = "text"; + defaultInput.autocomplete = "off"; + defaultInput.placeholder = "default"; + defaultInput.setAttribute("aria-label", "Default value"); + defaultInput.value = originalDefault; + defaultField.appendChild(defaultLabel); + defaultField.appendChild(defaultInput); + + var defaultExprId = "table-alter-column-default-expr-" + index; + var defaultExprField = document.createElement("div"); + defaultExprField.className = "table-alter-detail-field"; + var defaultExprLabel = document.createElement("label"); + defaultExprLabel.className = "table-alter-detail-label"; + defaultExprLabel.setAttribute("for", defaultExprId); + defaultExprLabel.textContent = "or default to a specific time"; + var defaultExprSelect = document.createElement("select"); + defaultExprSelect.id = defaultExprId; + defaultExprSelect.className = + "table-alter-input table-alter-default-expr"; + defaultExprSelect.setAttribute("aria-label", "or default to a specific time"); + tableAlterSelectDefaultExprValue(defaultExprSelect, ""); + defaultExprField.appendChild(defaultExprLabel); + defaultExprField.appendChild(defaultExprSelect); + + var pkLabel = document.createElement("label"); + pkLabel.className = "table-alter-detail-check table-alter-primary-key"; + var pkInput = document.createElement("input"); + pkInput.type = "checkbox"; + pkInput.className = "table-alter-primary-key-input"; + pkInput.checked = !!(column && column.is_pk); + var pkText = document.createElement("span"); + var pkStrong = document.createElement("strong"); + pkStrong.textContent = "Primary key"; + pkText.appendChild(pkStrong); + pkText.appendChild( + document.createTextNode(" An ID that uniquely identifies this record"), + ); + pkLabel.appendChild(pkInput); + pkLabel.appendChild(pkText); + + var moveControls = document.createElement("div"); + moveControls.className = "table-alter-move-controls"; + + var moveTopButton = document.createElement("button"); + moveTopButton.type = "button"; + moveTopButton.className = "table-alter-icon-button table-alter-move-top"; + moveTopButton.setAttribute("aria-label", "Move column to top"); + moveTopButton.title = "Move column to top"; + moveTopButton.dataset.defaultTitle = moveTopButton.title; + moveTopButton.innerHTML = + ''; + + var moveUpButton = document.createElement("button"); + moveUpButton.type = "button"; + moveUpButton.className = "table-alter-icon-button table-alter-move-up"; + moveUpButton.setAttribute("aria-label", "Move column up"); + moveUpButton.title = "Move column up"; + moveUpButton.dataset.defaultTitle = moveUpButton.title; + moveUpButton.innerHTML = + ''; + + var moveDownButton = document.createElement("button"); + moveDownButton.type = "button"; + moveDownButton.className = "table-alter-icon-button table-alter-move-down"; + moveDownButton.setAttribute("aria-label", "Move column down"); + moveDownButton.title = "Move column down"; + moveDownButton.dataset.defaultTitle = moveDownButton.title; + moveDownButton.innerHTML = + ''; + + var moveBottomButton = document.createElement("button"); + moveBottomButton.type = "button"; + moveBottomButton.className = "table-alter-icon-button table-alter-move-bottom"; + moveBottomButton.setAttribute("aria-label", "Move column to bottom"); + moveBottomButton.title = "Move column to bottom"; + moveBottomButton.dataset.defaultTitle = moveBottomButton.title; + moveBottomButton.innerHTML = + ''; + + var removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "table-alter-icon-button table-alter-remove-column"; + removeButton.setAttribute( + "aria-label", + existing ? "Drop column" : "Remove column", + ); + removeButton.title = existing ? "Drop column" : "Remove column"; + removeButton.innerHTML = + ''; + + moveControls.appendChild(moveTopButton); + moveControls.appendChild(moveUpButton); + moveControls.appendChild(moveDownButton); + moveControls.appendChild(moveBottomButton); + main.appendChild(nameLabel); + main.appendChild(nameInput); + main.appendChild(typeSelect); + if (customTypeSelect) { + main.appendChild(customTypeSelect); + } + main.appendChild(moveControls); + main.appendChild(removeButton); + main.appendChild(expandButton); + + details.appendChild(notNullLabel); + details.appendChild(defaultField); + details.appendChild(defaultExprField); + details.appendChild(pkLabel); + row.appendChild(main); + row.appendChild(details); + + var controls = [ + nameInput, + typeSelect, + notNullInput, + defaultInput, + defaultExprSelect, + pkInput, + ]; + if (customTypeSelect) { + controls.push(customTypeSelect); + } + controls.forEach(function (control) { + control.addEventListener("input", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + control.addEventListener("change", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + }); + + defaultInput.addEventListener("input", function () { + if (defaultInput.value) { + defaultExprSelect.value = ""; + syncTableAlterDefaultControls(row); + } + updateTableAlterSaveButtonState(state); + }); + defaultExprSelect.addEventListener("change", function () { + if (defaultExprSelect.value) { + defaultInput.value = ""; + } + syncTableAlterDefaultControls(row); + updateTableAlterSaveButtonState(state); + }); + pkInput.addEventListener("change", function () { + normalizeTableAlterPrimaryKeyRows(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + }); + + expandButton.addEventListener("click", function () { + var isExpanded = expandButton.getAttribute("aria-expanded") === "true"; + details.hidden = isExpanded; + expandButton.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + updateExpandButton(); + }); + + typeSelect.addEventListener("change", function () { + syncTableAlterCustomTypeForSqliteType(row); + updateTableAlterSaveButtonState(state); + }); + if (customTypeSelect) { + customTypeSelect.addEventListener("change", function () { + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + var option = tableAlterCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableAlterColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + } + updateTableAlterSaveButtonState(state); + }); + } + + moveTopButton.addEventListener("click", function () { + var first = tableAlterFirstNonPrimaryRow(state); + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !first || first === row) { + return; + } + state.columnList.insertBefore(row, first); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveUpButton.addEventListener("click", function () { + var previous = row.previousElementSibling; + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !previous || + tableAlterRowIsPrimaryKey(previous) + ) { + return; + } + state.columnList.insertBefore(row, previous); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveDownButton.addEventListener("click", function () { + var next = row.nextElementSibling; + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !next) { + return; + } + state.columnList.insertBefore(next, row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveBottomButton.addEventListener("click", function () { + var last = state.columnList.lastElementChild; + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !last || last === row) { + return; + } + state.columnList.appendChild(row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + removeButton.addEventListener("click", function () { + if (state.isSaving) { + return; + } + if (row.dataset.existing === "1") { + state.deletedColumns.push(row.dataset.originalName); + } + row.remove(); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + var nextInput = state.columnList.querySelector(".table-alter-column-name"); + if (nextInput) { + nextInput.focus(); + } else { + state.addColumnButton.focus(); + } + }); + + syncTableAlterDefaultControls(row); + return row; +} + +function addTableAlterColumn(state, column) { + var row = createTableAlterColumnRow(state, column || { type: "text" }); + state.columnList.appendChild(row); + return row; +} + +function resetTableAlterDialog(state, data) { + state.nextColumnIndex = 0; + state.deletedColumns = []; + state.originalPrimaryKeys = (data.primaryKeys || []).slice(); + state.originalColumnNames = (data.columns || []).map(function (column) { + return column.name; + }); + state.columnList.textContent = ""; + (data.columns || []).forEach(function (column) { + addTableAlterColumn( + state, + Object.assign({}, column, { + existing: true, + }), + ); + }); + normalizeTableAlterPrimaryKeyRows(state); + state.initialSignature = tableAlterDialogSignature(state); + showTableAlterEditor(state); +} + +function collectTableAlterRows(state) { + return tableAlterDialogRows(state).map(function (row) { + var signature = tableAlterRowSignature(row); + signature.originalType = row.dataset.originalType || ""; + signature.originalNotNull = row.dataset.originalNotNull === "1"; + signature.originalHasDefault = row.dataset.originalHasDefault === "1"; + signature.originalDefault = row.dataset.originalDefault || ""; + signature.originalPk = row.dataset.originalPk === "1"; + signature.originalCustomType = row.dataset.originalCustomType || ""; + return signature; + }); +} + +function validateTableAlterRows(state, rows) { + if (!rows.length) { + return "At least one column is required."; + } + var seen = {}; + var supportedTypes = tableAlterColumnTypes(); + for (var i = 0; i < rows.length; i += 1) { + var row = rows[i]; + var name = row.name.trim(); + if (!name) { + return "Column name is required."; + } + if (name.indexOf("\n") !== -1) { + return "Column names cannot contain newlines."; + } + var columnKey = name.toLowerCase(); + if (seen[columnKey]) { + return "Duplicate column name: " + name; + } + seen[columnKey] = true; + if (supportedTypes.indexOf(row.type) === -1) { + return "Unsupported column type: " + row.type; + } + if (row.customType) { + var option = tableAlterCustomColumnType(row.customType); + if (!option) { + return "Unknown custom column type: " + row.customType; + } + if (!tableAlterCustomTypeAppliesToSqliteType(option, row.type)) { + return ( + "Custom type " + + row.customType + + " cannot be used with SQLite type " + + row.type + + "." + ); + } + } + if (row.defaultValue && row.defaultExpr) { + return "Use either a default value or a default expression."; + } + if (!row.existing && row.notNull && !row.defaultValue && !row.defaultExpr) { + return "New NOT NULL columns need a default or default expression."; + } + } + var pkColumns = rows.filter(function (row) { + return row.pk; + }); + if (state.originalPrimaryKeys.length && !pkColumns.length) { + return "At least one primary key column is required."; + } + return null; +} + +function collectTableAlterColumnTypeAssignments(rows) { + var assignments = []; + if (!tableAlterCustomColumnTypes().length) { + return assignments; + } + rows.forEach(function (row) { + var renamed = row.existing && row.name.trim() !== row.originalName; + if (row.customType === row.originalCustomType && !renamed) { + return; + } + if (!row.customType && !row.originalCustomType) { + return; + } + assignments.push({ + column: row.name.trim(), + columnType: row.customType || null, + sqliteType: row.type, + }); + }); + return assignments; +} + +function tableAlterPkIdentityColumns(rows) { + return rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.existing ? row.originalName : row.name.trim(); + }); +} + +function tableAlterPkChanged(state, rows) { + return ( + JSON.stringify(tableAlterPkIdentityColumns(rows)) !== + JSON.stringify(state.originalPrimaryKeys) + ); +} + +function tableAlterNaturalColumnOrder(state, rows) { + var existingRowsByOriginalName = {}; + var newRows = []; + rows.forEach(function (row) { + if (row.existing) { + existingRowsByOriginalName[row.originalName] = row; + } else { + newRows.push(row); + } + }); + var naturalOrder = []; + state.originalColumnNames.forEach(function (originalName) { + var row = existingRowsByOriginalName[originalName]; + if (row) { + naturalOrder.push(row.name.trim()); + } + }); + newRows.forEach(function (row) { + naturalOrder.push(row.name.trim()); + }); + return naturalOrder; +} + +function tableAlterColumnsReordered(state, rows) { + var finalOrder = rows.map(function (row) { + return row.name.trim(); + }); + return ( + JSON.stringify(finalOrder) !== + JSON.stringify(tableAlterNaturalColumnOrder(state, rows)) + ); +} + +function collectTableAlterPayload(state) { + var rows = collectTableAlterRows(state); + var validationError = validateTableAlterRows(state, rows); + if (validationError) { + return { error: validationError }; + } + + var operations = []; + var columnTypeAssignments = collectTableAlterColumnTypeAssignments(rows); + rows.forEach(function (row) { + var name = row.name.trim(); + if (!row.existing) { + var addArgs = { + name: name, + type: row.type, + not_null: row.notNull, + }; + if (row.defaultExpr) { + addArgs.default_expr = row.defaultExpr; + } else if (row.defaultValue || row.notNull) { + addArgs.default = row.defaultValue; + } + operations.push({ op: "add_column", args: addArgs }); + return; + } + + var originalName = row.originalName; + if (name !== originalName) { + operations.push({ + op: "rename_column", + args: { name: originalName, to: name }, + }); + } + + var alterArgs = { name: originalName }; + if (row.type !== row.originalType) { + alterArgs.type = row.type; + } + if (row.notNull !== row.originalNotNull) { + alterArgs.not_null = row.notNull; + } + if (row.defaultExpr) { + alterArgs.default_expr = row.defaultExpr; + } else if (row.originalHasDefault) { + if (row.defaultValue !== row.originalDefault) { + alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; + } + } else if (row.defaultValue) { + alterArgs.default = row.defaultValue; + } + if (Object.keys(alterArgs).length > 1) { + operations.push({ op: "alter_column", args: alterArgs }); + } + }); + + state.deletedColumns.forEach(function (name) { + operations.push({ op: "drop_column", args: { name: name } }); + }); + + var pkColumns = rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.name.trim(); + }); + if (tableAlterPkChanged(state, rows)) { + operations.push({ op: "set_primary_key", args: { columns: pkColumns } }); + } + + if (tableAlterColumnsReordered(state, rows)) { + operations.push({ + op: "reorder_columns", + args: { + columns: rows.map(function (row) { + return row.name.trim(); + }), + }, + }); + } + + if (!operations.length && !columnTypeAssignments.length) { + return { error: "No changes to apply." }; + } + return { + payload: operations.length ? { operations: operations } : null, + columnTypeAssignments: columnTypeAssignments, + }; +} + +function tableAlterQuotedName(name) { + return '"' + name + '"'; +} + +function tableAlterReadableDefaultExpression(value) { + return value ? value.replace(/_/g, " ") : ""; +} + +function tableAlterReadableValue(value) { + if (value === null) { + return "NULL"; + } + return '"' + String(value) + '"'; +} + +function tableAlterOperationSummary(operation) { + var args = operation.args || {}; + if (operation.op === "add_column") { + var addDetails = ["as " + args.type]; + if (args.not_null) { + addDetails.push("with values required"); + } + if (args.default_expr) { + addDetails.push( + "defaulting to " + + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + addDetails.push( + "with default value " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Add column " + + tableAlterQuotedName(args.name) + + " " + + addDetails.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "rename_column") { + return { + text: + "Rename column " + + tableAlterQuotedName(args.name) + + " to " + + tableAlterQuotedName(args.to) + + ".", + damaging: false, + }; + } + if (operation.op === "alter_column") { + var changes = []; + if (args.type) { + changes.push("set type to " + args.type); + } + if (Object.prototype.hasOwnProperty.call(args, "not_null")) { + changes.push( + args.not_null + ? "not null (require values)" + : "allow unset values", + ); + } + if (args.default_expr) { + changes.push( + "default to " + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + changes.push( + args.default === null + ? "remove the default value" + : "set default value to " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Change column " + + tableAlterQuotedName(args.name) + + ": " + + changes.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "drop_column") { + return { + text: "Drop column " + tableAlterQuotedName(args.name) + ".", + damaging: true, + }; + } + if (operation.op === "set_primary_key") { + return { + text: + "Set primary key to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "reorder_columns") { + return { + text: + "Set column order to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + return { + text: "Run " + operation.op + ".", + damaging: false, + }; +} + +function tableAlterColumnTypeAssignmentSummary(assignment) { + return { + text: assignment.columnType + ? "Set custom type for column " + + tableAlterQuotedName(assignment.column) + + " to " + + assignment.columnType + + "." + : "Remove custom type from column " + + tableAlterQuotedName(assignment.column) + + ".", + damaging: false, + }; +} + +function tableAlterReviewItems(result) { + var items = []; + var operations = result.payload ? result.payload.operations || [] : []; + operations.forEach(function (operation) { + items.push(tableAlterOperationSummary(operation)); + }); + (result.columnTypeAssignments || []).forEach(function (assignment) { + items.push(tableAlterColumnTypeAssignmentSummary(assignment)); + }); + return items; +} + +function tableAlterReviewHasDamagingItems(items) { + return items.some(function (item) { + return item.damaging; + }); +} + +function appendTableAlterReviewText(element, text) { + text.split(/("[^"]+")/g).forEach(function (part) { + if (!part) { + return; + } + if (part.charAt(0) === '"' && part.charAt(part.length - 1) === '"') { + var name = document.createElement("code"); + name.className = "table-alter-review-name"; + name.textContent = part.slice(1, -1); + element.appendChild(name); + } else { + element.appendChild(document.createTextNode(part)); + } + }); +} + +function tableAlterSetColumnTypeUrl() { + var data = tableAlterData(); + if (!data || !data.path) { + return null; + } + var url = new URL(data.path, location.href); + url.pathname = url.pathname.replace(/\/-\/alter\/?$/, "/-/set-column-type"); + return url.toString(); +} + +async function assignTableAlterColumnTypes(assignments) { + if (!assignments.length) { + return; + } + var url = tableAlterSetColumnTypeUrl(); + 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: assignment.columnType + ? { + type: assignment.columnType, + } + : null, + }), + }); + 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( + "Saved schema changes, but could not set custom type for " + + assignment.column + + ": " + + error.message, + ); + } + } +} + +function showTableAlterEditor(state) { + state.mode = "edit"; + state.reviewResult = null; + state.dialog.classList.remove("table-alter-reviewing"); + state.fields.hidden = false; + state.review.hidden = true; + state.review.textContent = ""; + state.backButton.hidden = true; + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function showTableAlterReview(state, result) { + var items = tableAlterReviewItems(result); + state.mode = "review"; + state.reviewResult = result; + state.dialog.classList.add("table-alter-reviewing"); + state.fields.hidden = true; + state.review.hidden = false; + state.review.textContent = ""; + state.backButton.hidden = false; + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterSaveButtonState(state); + + var heading = document.createElement("h3"); + heading.className = "table-alter-review-title"; + heading.tabIndex = -1; + heading.textContent = "Review changes"; + state.review.appendChild(heading); + + var intro = document.createElement("p"); + intro.className = "table-alter-review-intro"; + intro.textContent = "These changes will be applied to the table."; + state.review.appendChild(intro); + + if (tableAlterReviewHasDamagingItems(items)) { + var warning = document.createElement("p"); + warning.className = "table-alter-review-warning"; + warning.setAttribute("role", "alert"); + warning.textContent = + "Warning: data in dropped columns will be permanently lost."; + state.review.appendChild(warning); + } + + var list = document.createElement("ol"); + list.className = "table-alter-review-list"; + items.forEach(function (item) { + var listItem = document.createElement("li"); + appendTableAlterReviewText(listItem, item.text); + if (item.damaging) { + listItem.className = "table-alter-review-damaging"; + } + list.appendChild(listItem); + }); + state.review.appendChild(list); + heading.focus(); +} + +async function applyTableAlterChanges(state, result) { + if (state.isSaving) { + return; + } + if (!result) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + var data = tableAlterData(); + if (!data || !data.path) { + showTableAlterDialogError(state, "Could not find the alter table URL."); + return; + } + clearTableAlterDialogError(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + setTableAlterDialogSaving(state, true); + try { + if (result.payload) { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(result.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 assignTableAlterColumnTypes(result.columnTypeAssignments || []); + state.shouldRestoreFocus = false; + state.dialog.close(); + location.reload(); + } catch (error) { + setTableAlterDialogSaving(state, false); + showTableAlterDialogError(state, error.message || "Could not alter table"); + } +} + +async function saveTableAlterDialog(state) { + if (state.isSaving) { + return; + } + if (state.mode === "review") { + if (!state.reviewResult) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + await applyTableAlterChanges(state, state.reviewResult); + return; + } + clearTableAlterDialogError(state); + var result = collectTableAlterPayload(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + showTableAlterReview(state, result); +} + +function confirmDiscardTableAlterChanges(state) { + if (!tableAlterDialogHasChanges(state)) { + return true; + } + return window.confirm("Discard table changes?"); +} + +function closeTableAlterDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardTableAlterChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function closeTableAlterDialog(state) { + if (!state || state.isSaving) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function ensureTableAlterDialog(manager) { + if (tableAlterDialogState) { + return tableAlterDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_ALTER_DIALOG_ID; + dialog.className = "table-alter-dialog"; + dialog.setAttribute("aria-labelledby", "table-alter-title"); + dialog.innerHTML = ` + +
+ +
+
+ +
+ +
+
+ + +
+ `; + document.body.appendChild(dialog); + + tableAlterDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-alter-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-alter-error"), + fields: dialog.querySelector(".table-alter-fields"), + review: dialog.querySelector(".table-alter-review"), + columnList: dialog.querySelector(".table-alter-column-list"), + addColumnButton: dialog.querySelector(".table-alter-add-column"), + backButton: dialog.querySelector(".table-alter-back"), + cancelButton: dialog.querySelector(".table-alter-cancel"), + saveButton: dialog.querySelector(".table-alter-save"), + currentButton: null, + shouldRestoreFocus: true, + isSaving: false, + initialSignature: "", + nextColumnIndex: 0, + deletedColumns: [], + originalColumnNames: [], + originalPrimaryKeys: [], + mode: "edit", + reviewResult: null, + manager: manager, + }; + + tableAlterDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.addColumnButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + var row = addTableAlterColumn(tableAlterDialogState, { + type: "text", + existing: false, + expanded: true, + }); + clearTableAlterDialogError(tableAlterDialogState); + updateTableAlterMoveButtons(tableAlterDialogState); + updateTableAlterSaveButtonState(tableAlterDialogState); + row.querySelector(".table-alter-column-name").focus(); + }); + + tableAlterDialogState.cancelButton.addEventListener("click", function () { + closeTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.backButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + clearTableAlterDialogError(tableAlterDialogState); + showTableAlterEditor(tableAlterDialogState); + var firstName = tableAlterDialogState.columnList.querySelector( + ".table-alter-column-name", + ); + if (firstName) { + firstName.focus(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("close", function () { + var state = tableAlterDialogState; + clearTableAlterDialogError(state); + setTableAlterDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableAlterDialogState; +} + +function openTableAlterDialog(button, manager) { + var data = tableAlterData(); + if (!data) { + return; + } + var state = ensureTableAlterDialog(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 = "Alter table " + data.tableName; + clearTableAlterDialogError(state); + resetTableAlterDialog(state, data); + if (!state.dialog.open) { + state.dialog.showModal(); + } + var firstName = state.columnList.querySelector(".table-alter-column-name"); + if (firstName) { + firstName.focus(); + } +} + +function initTableAlterActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !tableAlterData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-table-action="alter-table"]'); + if (!button) { + return; + } + ev.preventDefault(); + openTableAlterDialog(button, manager); + }); +} + function tableForeignKeys() { return tablePageData().foreignKeys || {}; } @@ -2696,6 +4171,7 @@ document.addEventListener("datasette_init", function (evt) { registerBuiltinColumnFieldPlugins(manager); initTableCreateActions(manager); + initTableAlterActions(manager); initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); diff --git a/datasette/views/row.py b/datasette/views/row.py index 4d61eb91..34773d48 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -212,6 +212,7 @@ class RowView(DataView): table, not is_table, None, + None, ), "row_actions": row_actions, "top_row": make_slot_function( diff --git a/datasette/views/table.py b/datasette/views/table.py index 11a28323..5175c7d9 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -50,7 +50,7 @@ from datasette.filters import Filters import sqlite_utils from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from .base import BaseView, DatasetteError, _error, stream_csv -from .database import QueryView +from .database import QueryView, _custom_column_type_options_for_create_table from .table_extras import ( TABLE_EXTRA_BUNDLES, TableExtraContext, @@ -62,6 +62,13 @@ LINK_WITH_LABEL = ( '{label} {id}' ) LINK_WITH_VALUE = '{id}' +ALTER_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] +ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { + SQLiteType.TEXT: "text", + SQLiteType.INTEGER: "integer", + SQLiteType.REAL: "float", + SQLiteType.BLOB: "blob", +} class Row: @@ -283,7 +290,14 @@ async def _foreign_key_autocomplete_urls( async def _table_page_data( - datasette, request, db, database_name, table_name, is_view, table_insert_ui + datasette, + request, + db, + database_name, + table_name, + is_view, + table_insert_ui, + table_alter_ui, ): data = { "database": database_name, @@ -292,6 +306,8 @@ async def _table_page_data( } if table_insert_ui: data["insertRow"] = table_insert_ui + if table_alter_ui: + data["alterTable"] = table_alter_ui if not is_view: foreign_keys = await _foreign_key_autocomplete_urls( datasette, request, db, database_name, table_name @@ -354,6 +370,63 @@ async def _table_insert_ui( } +async def _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks +): + if is_view or not db.is_mutable: + return None + + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_types_map = await datasette.get_column_types(database_name, table_name) + columns = [] + for column in await db.table_column_details(table_name): + if column.hidden: + continue + sqlite_type = SQLiteType.from_declared_type(column.type) + column_type = column_types_map.get(column.name) + columns.append( + { + "name": column.name, + "type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"), + "sqlite_type": sqlite_type.value, + "notnull": column.notnull, + "default": column.default_value, + "has_default": column.default_value is not None, + "is_pk": column.name in pks, + "column_type": ( + {"type": column_type.name, "config": column_type.config} + if column_type is not None + else None + ), + } + ) + + data = { + "path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)), + "tableName": table_name, + "columns": columns, + "primaryKeys": pks, + "columnTypes": ALTER_TABLE_COLUMN_TYPES, + "defaultExpressions": list(DEFAULT_EXPR_SQL), + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + return data + + async def display_columns_and_rows( datasette, database_name, @@ -2421,7 +2494,11 @@ async def table_view_data( table_insert_ui = await _table_insert_ui( datasette, request, db, database_name, table_name, is_view, pks ) + table_alter_ui = await _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks + ) data["table_insert_ui"] = table_insert_ui + data["table_alter_ui"] = table_alter_ui data["table_page_data"] = await _table_page_data( datasette, request, @@ -2430,6 +2507,7 @@ async def table_view_data( table_name, is_view, table_insert_ui, + table_alter_ui, ) return data, rows[:page_size], columns, expanded_columns, sql, next_url diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 0c7042f0..b7990009 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -130,6 +130,7 @@ def write_playwright_config(config_path): "notes": "textarea", }, "permissions": { + "alter-table": True, "insert-row": True, "update-row": True, "delete-row": True, @@ -328,6 +329,215 @@ def test_create_table_flow(page, datasette_server): } +@pytest.mark.playwright +def test_alter_table_flow(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + assert dialog.locator(".modal-title").inner_text() == "Alter table projects" + assert dialog.locator(".table-alter-save").is_disabled() + type_options = dialog.locator(".table-alter-column-type").first.locator("option") + assert type_options.all_inner_texts() == [ + "text", + "integer", + "floating point number", + "blob - binary data", + ] + first_more_options = dialog.locator(".table-alter-more-options").first + assert first_more_options.inner_text() == "> Advanced options" + first_more_options.click() + assert first_more_options.inner_text() == "v Hide options" + expanded_options_text = dialog.locator(".table-alter-column-details").first.inner_text() + assert dialog.locator(".table-alter-fields").evaluate( + "node => node.scrollWidth <= node.clientWidth + 1" + ) + assert "Not null" in expanded_options_text + assert "This value cannot be left unset" in expanded_options_text + assert "Default value" in expanded_options_text + assert "or default to a specific time" in expanded_options_text + assert "Primary key" in expanded_options_text + assert "An ID that uniquely identifies this record" in expanded_options_text + + dialog.locator(".table-alter-add-column").click() + assert dialog.locator(".table-alter-save").is_enabled() + dialog.locator(".table-alter-column-name").last.fill("status") + dialog.locator(".table-alter-column-type").last.select_option("text") + dialog.locator(".table-alter-default").last.fill("planned") + dialog.locator(".table-alter-save").click() + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Add column status as text, with default value planned." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-back").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Apply changes" + dialog.locator(".table-alter-save").click() + + columns = [] + for _ in range(20): + response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns") + response.raise_for_status() + columns = response.json()["columns"] + if "status" in columns: + break + time.sleep(0.1) + assert "status" in columns + + +@pytest.mark.playwright +def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + rows = dialog.locator(".table-alter-column-row") + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button") + for i in range(first_row_move_buttons.count()): + assert first_row_move_buttons.nth(i).is_disabled() + assert ( + first_row_move_buttons.nth(i).get_attribute("title") + == "Primary key columns are always listed first" + ) + + assert rows.nth(1).locator(".table-alter-move-up").is_disabled() + assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + last_row = rows.nth(rows.count() - 1) + assert last_row.locator(".table-alter-column-name").input_value() == "score" + last_row.locator(".table-alter-move-top").click() + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score" + + +@pytest.mark.playwright +def test_alter_table_review_rename_primary_key_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + save = dialog.locator(".table-alter-save") + assert save.is_disabled() + dialog.locator(".table-alter-column-name").first.fill("id3") + assert save.is_enabled() + save.click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + review_text = review.inner_text() + assert "Rename column id to id3." in review_text + assert "Set primary key to" not in review_text + assert dialog.locator(".table-alter-review-name").all_inner_texts() == [ + "id", + "id3", + ] + + +@pytest.mark.playwright +def test_alter_table_review_not_null_wording(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + dialog.locator(".table-alter-more-options").first.click() + dialog.locator(".table-alter-not-null-input").first.check() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert "Change column id: not null (require values)." in review.inner_text() + + +@pytest.mark.playwright +def test_alter_table_review_warns_when_dropping_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + remove_buttons = dialog.locator(".table-alter-remove-column") + remove_buttons.nth(remove_buttons.count() - 1).click() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Warning: data in dropped columns will be permanently lost." in review_text + assert "Drop column score." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-review-damaging").inner_text() == ( + "Drop column score." + ) + + dialog.locator(".table-alter-back").click() + assert dialog.locator(".table-alter-column-list").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Review changes" + + +@pytest.mark.playwright +def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): + def open_alter_dialog(): + page.locator("details.actions-menu-links").evaluate("node => node.open = true") + page.locator('button[data-table-action="alter-table"]').click() + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + return dialog + + page.goto(f"{datasette_server}data/projects") + page.evaluate( + """ + () => { + window.__discardConfirmMessages = []; + window.confirm = (message) => { + window.__discardConfirmMessages.push(message); + return false; + }; + } + """ + ) + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("cancel_me") + dialog.locator(".table-alter-cancel").click() + assert dialog.evaluate("node => node.open") is False + assert page.evaluate("() => window.__discardConfirmMessages") == [] + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("escape_me") + page.keyboard.press("Escape") + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + page.evaluate("() => window.__discardConfirmMessages = []") + dialog.evaluate( + """node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))""" + ) + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 887fdf50..374cc08d 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1078,6 +1078,123 @@ async def test_database_create_table_data_includes_custom_column_types(): ds.close() +@pytest.mark.asyncio +async def test_table_alter_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "alter-table": {"id": "root"}, + "set-column-type": {"id": "root"}, + }, + "column_types": {"name": "textarea"}, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_alter_action"), name="data" + ) + await db.execute_write_script(""" + create table items ( + id integer primary key, + name text not null, + score integer default 5 + ); + """) + response = await ds.client.get("/data/items", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.action-menu-button[data-table-action="alter-table"]' + ) + assert button is not None + assert button["aria-label"] == "Alter table items" + assert button["role"] == "menuitem" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == ( + "Change columns and primary key for this table." + ) + description.extract() + assert button.text.strip() == "Alter table" + assert any( + "edit-tools.js" in script.get("src", "") + for script in soup.find_all("script") + ) + + alter_data = table_data_from_soup(soup)["alterTable"] + assert alter_data["path"] == "/data/items/-/alter" + assert alter_data["tableName"] == "items" + assert alter_data["primaryKeys"] == ["id"] + assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"] + assert alter_data["defaultExpressions"] == [ + "current_timestamp", + "current_date", + "current_time", + ] + assert [option["name"] for option in alter_data["customColumnTypes"]] == [ + "email", + "json", + "textarea", + "url", + ] + assert alter_data["columns"] == [ + { + "name": "id", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": True, + "column_type": None, + }, + { + "name": "name", + "type": "text", + "sqlite_type": "TEXT", + "notnull": 1, + "default": None, + "has_default": False, + "is_pk": False, + "column_type": {"type": "textarea", "config": None}, + }, + { + "name": "score", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": "5", + "has_default": True, + "is_pk": False, + "column_type": None, + }, + ] + + response_without_permission = await ds.client.get( + "/data/items", actor={"id": "someone-else"} + ) + assert response_without_permission.status_code == 200 + soup_without_permission = Soup(response_without_permission.text, "html.parser") + assert ( + soup_without_permission.select_one( + 'button[data-table-action="alter-table"]' + ) + is None + ) + assert "alterTable" not in table_data_from_soup(soup_without_permission) + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_insert_action_button_and_data(): ds = Datasette(