From b7a896a80318e5eafe36d9f5ab8bde4a799eb7a9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 Jun 2026 22:02:39 -0700 Subject: [PATCH 01/31] Insert multiple rows feature in the insert dialog Works entirely using existing JSON API. User can paste in CSV, TSV or JSON (or drop a text file on). They get a preview of the rows to be imported, then they are imported in max_insert_rows batches. --- datasette/static/app.css | 156 ++++++- datasette/static/edit-tools.js | 797 ++++++++++++++++++++++++++++++++- datasette/views/table.py | 1 + tests/test_playwright.py | 117 +++++ tests/test_table_html.py | 1 + 5 files changed, 1062 insertions(+), 10 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index ce800f61..ca51c099 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1641,6 +1641,11 @@ dialog.row-edit-dialog::backdrop { overflow-y: auto; } +.row-edit-fields[hidden], +.row-edit-bulk[hidden] { + display: none; +} + .row-edit-field { display: grid; grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); @@ -1798,6 +1803,144 @@ textarea.row-edit-input { margin: 0; } +.row-edit-bulk { + display: grid; + gap: 8px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.row-edit-bulk-editor { + display: grid; + gap: 8px; +} + +.row-edit-bulk-editor[hidden] { + display: none; +} + +.row-edit-bulk-label { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.row-edit-bulk-actions { + display: flex; + justify-content: flex-end; +} + +.row-edit-bulk-actions .btn { + padding-left: 12px; + padding-right: 12px; +} + +.row-edit-bulk-textarea { + min-height: 16rem; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.row-edit-bulk-textarea.row-edit-bulk-drop-target { + border-color: var(--accent); + background: #f8fbff; + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-bulk-note { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.row-edit-bulk-preview { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.row-edit-bulk-preview[hidden] { + display: none; +} + +.row-edit-bulk-preview-summary { + color: var(--ink); + font-size: 0.9rem; + font-weight: 600; + margin: 0; +} + +.row-edit-bulk-preview-table-wrap { + border: 1px solid var(--rule); + border-radius: 5px; + max-height: 18rem; + overflow: auto; + background: #fff; +} + +.row-edit-bulk-preview-table { + border-collapse: collapse; + font-size: 0.78rem; + min-width: 100%; + width: max-content; +} + +.row-edit-bulk-preview-table th, +.row-edit-bulk-preview-table td { + border-bottom: 1px solid var(--rule); + border-right: 1px solid var(--rule); + max-width: 18rem; + padding: 6px 8px; + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.row-edit-bulk-preview-table th { + background: var(--paper); + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.row-edit-bulk-preview-table tr:last-child td { + border-bottom: none; +} + +.row-edit-bulk-preview-table th:last-child, +.row-edit-bulk-preview-table td:last-child { + border-right: none; +} + +.row-edit-bulk-preview-null { + color: var(--muted); + font-style: italic; +} + +.row-edit-bulk-progress { + display: grid; + gap: 6px; +} + +.row-edit-bulk-progress[hidden] { + display: none; +} + +.row-edit-bulk-progress-bar { + width: 100%; +} + +.row-edit-bulk-progress-status { + color: var(--ink); + font-size: 0.9rem; + margin: 0; +} + datasette-autocomplete { display: block; position: relative; @@ -1876,6 +2019,16 @@ datasette-autocomplete input[type="text"], background: var(--paper); } +.row-edit-mode-link { + color: var(--accent); + font-size: 0.9rem; + margin-right: auto; +} + +.row-edit-mode-link[hidden] { + display: none; +} + .row-edit-dialog .btn { border: none; border-radius: 5px; @@ -3012,7 +3165,8 @@ select.table-alter-input { .row-edit-dialog .modal-header, .row-edit-summary, .row-edit-loading, - .row-edit-fields { + .row-edit-fields, + .row-edit-bulk { padding-left: 18px; padding-right: 18px; } diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9f4f89b9..58ddaf19 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -4435,12 +4435,51 @@ function showRowEditDialogError(state, message) { state.error.focus(); } +function rowEditIsMultipleInsert(state) { + return state.mode === "insert" && state.insertMode === "multiple"; +} + +function syncRowEditInsertModeUi(state) { + var isInsert = state.mode === "insert"; + var isMultiple = rowEditIsMultipleInsert(state); + state.fields.hidden = isMultiple; + state.bulkInsertPanel.hidden = !isMultiple; + state.bulkInsertEditor.hidden = !isMultiple || state.bulkInsertPreviewReady; + state.bulkInsertPreview.hidden = !isMultiple || !state.bulkInsertPreviewReady; + state.bulkInsertLink.hidden = !isInsert || isMultiple; + state.singleInsertLink.hidden = !isInsert || !isMultiple; +} + function updateRowEditDialogButtons(state) { state.saveButton.disabled = - state.isLoading || state.isSaving || !state.hasLoaded; + state.isLoading || + state.isSaving || + !state.hasLoaded || + state.bulkInsertInserted; state.cancelButton.disabled = state.isSaving; - var saveLabel = state.mode === "insert" ? "Insert row" : "Save"; - state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel; + syncRowEditInsertModeUi(state); + state.cancelButton.textContent = + rowEditIsMultipleInsert(state) && + state.bulkInsertPreviewReady && + !state.bulkInsertInserted + ? "Back" + : state.bulkInsertInserted + ? "Close and view table" + : "Cancel"; + var saveLabel = rowEditIsMultipleInsert(state) + ? state.bulkInsertInserted + ? "Inserted" + : state.bulkInsertPreviewReady + ? "Insert these rows" + : "Preview rows" + : state.mode === "insert" + ? "Insert row" + : "Save"; + state.saveButton.textContent = state.isSaving + ? rowEditIsMultipleInsert(state) + ? "Inserting..." + : "Saving..." + : saveLabel; state.form.setAttribute( "aria-busy", state.isLoading || state.isSaving ? "true" : "false", @@ -4631,6 +4670,16 @@ function rowEditDialogHasChanges(state) { if (!state || !state.hasLoaded || state.isLoading) { return false; } + if (state.bulkInsertInserted) { + return false; + } + if ( + state.mode === "insert" && + state.bulkInsertTextarea && + state.bulkInsertTextarea.value.trim() + ) { + return true; + } var fields = state.fields.querySelectorAll(".row-edit-field"); for (var i = 0; i < fields.length; i += 1) { var fieldApi = fields[i]._datasetteColumnFormField; @@ -4664,6 +4713,549 @@ function closeRowEditDialogIfConfirmed(state) { return true; } +function setRowInsertDialogTitle(state) { + var insertData = tableInsertData() || {}; + var title = rowEditIsMultipleInsert(state) + ? "Insert multiple rows" + : "Insert row"; + setRowDialogTitle( + state.title, + insertData.tableName ? title + " into " + insertData.tableName : title, + ); +} + +function showMultipleRowInsert(state) { + if (!state || state.mode !== "insert" || state.isSaving) { + return; + } + state.insertMode = "multiple"; + if (state.bulkInsertPreviewReady || state.bulkInsertInserted) { + resetBulkInsertPreview(state); + } + clearRowEditDialogError(state); + setRowInsertDialogTitle(state); + updateRowEditDialogButtons(state); + state.bulkInsertTextarea.focus(); +} + +function showSingleRowInsert(state) { + if (!state || state.mode !== "insert" || state.isSaving) { + return; + } + state.insertMode = "single"; + clearRowEditDialogError(state); + setRowInsertDialogTitle(state); + updateRowEditDialogButtons(state); + if (!focusFirstRowEditControl(state, { skipReadonly: true })) { + state.saveButton.focus(); + } +} + +function readTextFile(file) { + if (file.text) { + return file.text(); + } + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { + resolve(reader.result || ""); + }; + reader.onerror = function () { + reject(reader.error); + }; + reader.readAsText(file); + }); +} + +function bulkInsertTemplateText(state) { + return (state.bulkInsertColumns || []).join("\t"); +} + +async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + var textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + document.body.appendChild(textarea); + textarea.select(); + var copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) { + throw new Error("copy failed"); + } +} + +function setBulkInsertCopyButtonCopied(state) { + state.copyTemplateButton.textContent = "Copied"; + clearTimeout(state.copyTemplateResetTimer); + state.copyTemplateResetTimer = setTimeout(function () { + state.copyTemplateButton.textContent = "Copy spreadsheet template"; + }, 1500); +} + +function resetBulkInsertPreview(state) { + state.bulkInsertPreviewRows = null; + state.bulkInsertPreviewReady = false; + state.bulkInsertInserted = false; + state.bulkInsertInsertedCount = 0; + state.bulkInsertPreview.hidden = true; + state.bulkInsertPreview.textContent = ""; + state.bulkInsertProgress.hidden = true; + state.bulkInsertProgressBar.value = 0; + state.bulkInsertProgressBar.max = 1; + state.bulkInsertProgressStatus.textContent = ""; + syncRowEditInsertModeUi(state); +} + +function normalizeBulkInsertCell(column, value, isMissing) { + if (isMissing || typeof value === "undefined") { + return column.notnull ? "" : null; + } + if (value === null) { + return column.notnull ? "" : null; + } + if (value === "" && column.notnull) { + return ""; + } + if (column.value_kind === "number" && typeof value === "string") { + return valueFromRowEditText(column.name, value, "number"); + } + return value; +} + +function rowObjectForBulkInsert(valuesByColumn, columns) { + var row = {}; + columns.forEach(function (column) { + var hasValue = Object.prototype.hasOwnProperty.call( + valuesByColumn, + column.name, + ); + row[column.name] = normalizeBulkInsertCell( + column, + hasValue ? valuesByColumn[column.name] : undefined, + !hasValue, + ); + }); + return row; +} + +function splitDelimitedRows(text, delimiter) { + var rows = []; + var row = []; + var cell = ""; + var inQuotes = false; + + for (var i = 0; i < text.length; i += 1) { + var character = text[i]; + if (inQuotes) { + if (character === '"') { + if (text[i + 1] === '"') { + cell += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + cell += character; + } + continue; + } + + if (character === '"') { + inQuotes = true; + } else if (character === delimiter) { + row.push(cell); + cell = ""; + } else if (character === "\n" || character === "\r") { + row.push(cell); + rows.push(row); + row = []; + cell = ""; + if (character === "\r" && text[i + 1] === "\n") { + i += 1; + } + } else { + cell += character; + } + } + + if (inQuotes) { + throw new Error("Unclosed quoted value."); + } + row.push(cell); + rows.push(row); + + while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) { + rows.pop(); + } + return rows; +} + +function bulkInsertDelimitedRowIsBlank(row) { + return row.every(function (value) { + return value.trim() === ""; + }); +} + +function delimiterPreviewRows(text, delimiter) { + try { + return splitDelimitedRows(text, delimiter); + } catch (_error) { + return []; + } +} + +function splitSingleColumnRows(text) { + var rows = text.split(/\r\n|\n|\r/).map(function (line) { + return [line]; + }); + while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) { + rows.pop(); + } + return rows; +} + +function detectBulkInsertDelimiter(text, columns) { + var firstLine = + text.split(/\r\n|\n|\r/).find(function (line) { + return line.trim() !== ""; + }) || ""; + var csvRows = delimiterPreviewRows(firstLine, ","); + var tsvRows = delimiterPreviewRows(firstLine, "\t"); + var csvColumns = csvRows.length ? csvRows[0].length : 0; + var tsvColumns = tsvRows.length ? tsvRows[0].length : 0; + + if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) { + return "\t"; + } + if (tsvColumns > csvColumns) { + return "\t"; + } + if (csvColumns > 1) { + return ","; + } + if (tsvColumns > 1) { + return "\t"; + } + if (columns.length === 1 || bulkInsertColumnMap(columns)[firstLine.trim()]) { + return null; + } + throw new Error("Could not detect CSV or TSV columns."); +} + +function bulkInsertColumnMap(columns) { + var map = {}; + columns.forEach(function (column) { + map[column.name] = column; + }); + return map; +} + +function parseJsonBulkInsertRows(text, columns) { + var parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error("Invalid JSON: " + error.message); + } + if (!Array.isArray(parsed)) { + throw new Error("JSON must be an array of objects."); + } + if (!parsed.length) { + throw new Error("No rows found to preview."); + } + + var columnMap = bulkInsertColumnMap(columns); + return parsed.map(function (item, index) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error("JSON row " + (index + 1) + " must be an object."); + } + Object.keys(item).forEach(function (key) { + if (!columnMap[key]) { + throw new Error( + "JSON row " + (index + 1) + " has unknown column " + key + ".", + ); + } + }); + return rowObjectForBulkInsert(item, columns); + }); +} + +function parseDelimitedBulkInsertRows(text, columns) { + var delimiter = detectBulkInsertDelimiter(text, columns); + var rows = ( + delimiter === null + ? splitSingleColumnRows(text) + : splitDelimitedRows(text, delimiter) + ).filter(function (row) { + return !bulkInsertDelimitedRowIsBlank(row); + }); + if (!rows.length) { + throw new Error("No rows found to preview."); + } + + var columnMap = bulkInsertColumnMap(columns); + var header = rows[0].map(function (value) { + return value.trim(); + }); + var headerMatches = header.filter(function (name) { + return !!columnMap[name]; + }).length; + var hasHeader = headerMatches > 0; + var dataRows = hasHeader ? rows.slice(1) : rows; + var headers = hasHeader + ? header + : columns.map(function (column) { + return column.name; + }); + var seenHeaders = {}; + + if (hasHeader) { + headers.forEach(function (name) { + if (!name) { + return; + } + if (!columnMap[name]) { + throw new Error("Unknown column " + name + " in header row."); + } + if (seenHeaders[name]) { + throw new Error("Duplicate column " + name + " in header row."); + } + seenHeaders[name] = true; + }); + } + + if (!dataRows.length) { + throw new Error("No data rows found to preview."); + } + + return dataRows.map(function (row, rowIndex) { + if (row.length > headers.length) { + throw new Error( + "Row " + + (rowIndex + 1) + + " has " + + row.length + + " values, but only " + + headers.length + + " columns were provided.", + ); + } + var valuesByColumn = {}; + row.forEach(function (value, index) { + var columnName = headers[index]; + if (columnMap[columnName]) { + valuesByColumn[columnName] = value; + } + }); + return rowObjectForBulkInsert(valuesByColumn, columns); + }); +} + +function parseBulkInsertRows(text, columns) { + var trimmed = text.trim(); + if (!trimmed) { + throw new Error("Paste rows before previewing."); + } + if (trimmed[0] === "[" || trimmed[0] === "{") { + return parseJsonBulkInsertRows(trimmed, columns); + } + return parseDelimitedBulkInsertRows(trimmed, columns); +} + +function bulkInsertPreviewValue(value) { + if (value === null) { + return "null"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +function renderBulkInsertPreview(state, rows) { + state.bulkInsertPreview.textContent = ""; + var summary = document.createElement("p"); + summary.className = "row-edit-bulk-preview-summary"; + summary.textContent = + "Previewing " + rows.length + " row" + (rows.length === 1 ? "." : "s."); + state.bulkInsertPreview.appendChild(summary); + + var tableWrap = document.createElement("div"); + tableWrap.className = "row-edit-bulk-preview-table-wrap"; + var table = document.createElement("table"); + table.className = "row-edit-bulk-preview-table"; + var thead = document.createElement("thead"); + var headerRow = document.createElement("tr"); + state.bulkInsertColumnDetails.forEach(function (column) { + var th = document.createElement("th"); + th.scope = "col"; + th.textContent = column.name; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + var tbody = document.createElement("tbody"); + rows.forEach(function (row) { + var tr = document.createElement("tr"); + state.bulkInsertColumnDetails.forEach(function (column) { + var td = document.createElement("td"); + var value = row[column.name]; + td.textContent = bulkInsertPreviewValue(value); + if (value === null) { + td.className = "row-edit-bulk-preview-null"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + tableWrap.appendChild(table); + state.bulkInsertPreview.appendChild(tableWrap); + state.bulkInsertPreview.hidden = false; +} + +function previewBulkInsertRows(state) { + clearRowEditDialogError(state); + resetBulkInsertPreview(state); + try { + var rows = parseBulkInsertRows( + state.bulkInsertTextarea.value, + state.bulkInsertColumnDetails, + ); + state.bulkInsertPreviewRows = rows; + state.bulkInsertPreviewReady = true; + renderBulkInsertPreview(state, rows); + updateRowEditDialogButtons(state); + } catch (error) { + showRowEditDialogError(state, error.message || "Could not preview rows."); + updateRowEditDialogButtons(state); + } +} + +function updateBulkInsertProgress(state, inserted, total) { + state.bulkInsertProgress.hidden = false; + state.bulkInsertProgressBar.max = total || 1; + state.bulkInsertProgressBar.value = inserted; + state.bulkInsertProgressStatus.textContent = + inserted >= total + ? total + " row" + (total === 1 ? " inserted." : "s inserted.") + : "Inserting " + inserted + " of " + total + " rows..."; +} + +function bulkInsertBatches(rows, batchSize) { + var batches = []; + var size = Math.max(1, batchSize || 1); + for (var index = 0; index < rows.length; index += size) { + batches.push(rows.slice(index, index + size)); + } + return batches; +} + +function animateBulkInsertProgress(state, from, to, total, duration) { + state.bulkInsertProgress.hidden = false; + state.bulkInsertProgressBar.max = total || 1; + if (duration <= 0 || !window.requestAnimationFrame) { + updateBulkInsertProgress(state, to, total); + return Promise.resolve(); + } + + return new Promise(function (resolve) { + var startTime = null; + var step = function (timestamp) { + if (startTime === null) { + startTime = timestamp; + } + var progress = Math.min((timestamp - startTime) / duration, 1); + var easedProgress = 1 - Math.pow(1 - progress, 3); + var value = from + (to - from) * easedProgress; + var displayValue = progress === 1 ? to : Math.floor(value); + state.bulkInsertProgressBar.value = value; + state.bulkInsertProgressStatus.textContent = + displayValue >= total + ? total + " row" + (total === 1 ? " inserted." : "s inserted.") + : "Inserting " + displayValue + " of " + total + " rows..."; + if (progress < 1) { + window.requestAnimationFrame(step); + } else { + updateBulkInsertProgress(state, to, total); + resolve(); + } + }; + window.requestAnimationFrame(step); + }); +} + +async function insertBulkPreviewRows(state) { + if (!state.bulkInsertPreviewRows || state.bulkInsertInserted) { + return; + } + if (!state.currentInsertUrl) { + showRowEditDialogError(state, "Could not find the row insert URL."); + return; + } + + var rows = state.bulkInsertPreviewRows; + var total = rows.length; + var inserted = state.bulkInsertInsertedCount || 0; + var batches = bulkInsertBatches( + rows.slice(inserted), + state.bulkInsertMaxRows, + ); + var progressAnimationDuration = 500 / Math.max(batches.length, 1); + + clearRowEditDialogError(state); + updateBulkInsertProgress(state, inserted, total); + setRowEditDialogSaving(state, true); + try { + for (var batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { + var batch = batches[batchIndex]; + var response = await fetch(state.currentInsertUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ rows: batch }), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + throw rowMutationRequestError(response, data); + } + var previousInserted = inserted; + inserted += batch.length; + state.bulkInsertInsertedCount = inserted; + await animateBulkInsertProgress( + state, + previousInserted, + inserted, + total, + progressAnimationDuration, + ); + } + state.bulkInsertInserted = true; + state.shouldReloadOnClose = true; + state.redirectOnCloseUrl = tableBaseUrl().toString(); + updateBulkInsertProgress(state, inserted, total); + } catch (error) { + showRowEditDialogError(state, error.message || "Could not insert rows."); + } finally { + setRowEditDialogSaving(state, false); + } +} + function scheduleCloseRowEditDialogIfConfirmed(state) { // Fix for an issue in Safari where hitting Esc would show // the confirm() prompt asking if state should be discarded @@ -4762,6 +5354,14 @@ async function saveRowEditDialog(state) { if (state.isLoading || state.isSaving || !state.hasLoaded) { return; } + if (rowEditIsMultipleInsert(state)) { + if (!state.bulkInsertPreviewReady) { + previewBulkInsertRows(state); + } else if (!state.bulkInsertInserted) { + await insertBulkPreviewRows(state); + } + return; + } clearRowEditDialogError(state); setRowEditDialogSaving(state, true); @@ -4920,6 +5520,7 @@ function renderRowEditFields(state, data) { var primaryKeys = data.primary_keys || []; var columnTypes = data.column_types || {}; + state.insertMode = "single"; destroyRowEditFields(state); columns.forEach(function (column, index) { state.fields.appendChild( @@ -4951,6 +5552,17 @@ function renderRowEditFields(state, data) { function renderRowInsertFields(state, data) { var columns = data.columns || []; + state.insertMode = "single"; + state.bulkInsertColumnDetails = columns.slice(); + state.bulkInsertMaxRows = data.maxInsertRows || 100; + state.bulkInsertColumns = columns.map(function (column) { + return column.name; + }); + state.copyTemplateButton.disabled = !state.bulkInsertColumns.length; + state.copyTemplateButton.textContent = "Copy spreadsheet template"; + clearTimeout(state.copyTemplateResetTimer); + state.copyTemplateResetTimer = null; + resetBulkInsertPreview(state); destroyRowEditFields(state); columns.forEach(function (column, index) { state.fields.appendChild( @@ -5040,7 +5652,24 @@ function ensureRowEditDialog(manager) {

Loading row...

+ @@ -5056,6 +5685,18 @@ function ensureRowEditDialog(manager) { loading: dialog.querySelector(".row-edit-loading"), error: dialog.querySelector(".row-edit-error"), fields: dialog.querySelector(".row-edit-fields"), + bulkInsertPanel: dialog.querySelector(".row-edit-bulk"), + bulkInsertEditor: dialog.querySelector(".row-edit-bulk-editor"), + bulkInsertTextarea: dialog.querySelector(".row-edit-bulk-textarea"), + bulkInsertPreview: dialog.querySelector(".row-edit-bulk-preview"), + bulkInsertProgress: dialog.querySelector(".row-edit-bulk-progress"), + bulkInsertProgressBar: dialog.querySelector(".row-edit-bulk-progress-bar"), + bulkInsertProgressStatus: dialog.querySelector( + ".row-edit-bulk-progress-status", + ), + copyTemplateButton: dialog.querySelector(".row-edit-copy-template"), + bulkInsertLink: dialog.querySelector(".row-edit-bulk-insert"), + singleInsertLink: dialog.querySelector(".row-edit-single-insert"), cancelButton: dialog.querySelector(".row-edit-cancel"), saveButton: dialog.querySelector(".row-edit-save"), currentButton: null, @@ -5066,6 +5707,17 @@ function ensureRowEditDialog(manager) { currentUpdateUrl: null, currentFragmentUrl: null, mode: "edit", + insertMode: "single", + bulkInsertColumns: [], + bulkInsertColumnDetails: [], + bulkInsertPreviewRows: null, + bulkInsertPreviewReady: false, + bulkInsertInserted: false, + bulkInsertInsertedCount: 0, + bulkInsertMaxRows: 100, + shouldReloadOnClose: false, + redirectOnCloseUrl: null, + copyTemplateResetTimer: null, loadId: 0, manager: manager, isLoading: false, @@ -5081,12 +5733,121 @@ function ensureRowEditDialog(manager) { }); rowEditDialogState.cancelButton.addEventListener("click", function () { + if ( + rowEditIsMultipleInsert(rowEditDialogState) && + rowEditDialogState.bulkInsertPreviewReady && + !rowEditDialogState.bulkInsertInserted && + !rowEditDialogState.isSaving + ) { + resetBulkInsertPreview(rowEditDialogState); + updateRowEditDialogButtons(rowEditDialogState); + rowEditDialogState.bulkInsertTextarea.focus(); + return; + } if (!rowEditDialogState.isSaving) { rowEditDialogState.shouldRestoreFocus = true; dialog.close(); } }); + rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) { + ev.preventDefault(); + showMultipleRowInsert(rowEditDialogState); + }); + + rowEditDialogState.singleInsertLink.addEventListener("click", function (ev) { + ev.preventDefault(); + showSingleRowInsert(rowEditDialogState); + }); + + rowEditDialogState.copyTemplateButton.addEventListener( + "click", + async function () { + try { + await copyTextToClipboard(bulkInsertTemplateText(rowEditDialogState)); + clearRowEditDialogError(rowEditDialogState); + setBulkInsertCopyButtonCopied(rowEditDialogState); + } catch (_error) { + showRowEditDialogError( + rowEditDialogState, + "Could not copy the spreadsheet template.", + ); + } + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener( + "dragenter", + function (ev) { + ev.preventDefault(); + rowEditDialogState.bulkInsertTextarea.classList.add( + "row-edit-bulk-drop-target", + ); + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener( + "dragover", + function (ev) { + ev.preventDefault(); + rowEditDialogState.bulkInsertTextarea.classList.add( + "row-edit-bulk-drop-target", + ); + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener( + "dragleave", + function () { + rowEditDialogState.bulkInsertTextarea.classList.remove( + "row-edit-bulk-drop-target", + ); + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener( + "drop", + async function (ev) { + ev.preventDefault(); + rowEditDialogState.bulkInsertTextarea.classList.remove( + "row-edit-bulk-drop-target", + ); + var files = ev.dataTransfer && ev.dataTransfer.files; + if (!files || !files.length) { + return; + } + try { + rowEditDialogState.bulkInsertTextarea.value = await readTextFile( + files[0], + ); + rowEditDialogState.bulkInsertTextarea.dispatchEvent( + new Event("input", { bubbles: true }), + ); + clearRowEditDialogError(rowEditDialogState); + } catch (_error) { + showRowEditDialogError( + rowEditDialogState, + "Could not read that text file.", + ); + } + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener( + "dragend", + function () { + rowEditDialogState.bulkInsertTextarea.classList.remove( + "row-edit-bulk-drop-target", + ); + }, + ); + + rowEditDialogState.bulkInsertTextarea.addEventListener("input", function () { + resetBulkInsertPreview(rowEditDialogState); + clearRowEditDialogError(rowEditDialogState); + updateRowEditDialogButtons(rowEditDialogState); + }); + dialog.addEventListener("click", function (ev) { if (ev.target === dialog) { closeRowEditDialogIfConfirmed(rowEditDialogState); @@ -5108,8 +5869,16 @@ function ensureRowEditDialog(manager) { dialog.addEventListener("close", function () { var state = rowEditDialogState; + var shouldReloadOnClose = state.shouldReloadOnClose; + var redirectOnCloseUrl = state.redirectOnCloseUrl; state.loadId += 1; state.isClosePending = false; + state.shouldReloadOnClose = false; + state.redirectOnCloseUrl = null; + clearTimeout(state.copyTemplateResetTimer); + state.copyTemplateResetTimer = null; + state.copyTemplateButton.textContent = "Copy spreadsheet template"; + resetBulkInsertPreview(state); clearRowEditDialogError(state); state.hasLoaded = false; destroyRowEditFields(state); @@ -5122,6 +5891,13 @@ function ensureRowEditDialog(manager) { ) { state.currentButton.focus(); } + if (shouldReloadOnClose) { + if (redirectOnCloseUrl) { + location.href = redirectOnCloseUrl; + } else { + location.reload(); + } + } }); return rowEditDialogState; @@ -5146,6 +5922,7 @@ async function openRowEditDialog(button, manager) { state.currentInsertUrl = null; state.currentUpdateUrl = rowUpdateUrl(row); state.currentFragmentUrl = rowFragmentUrl(row); + state.insertMode = "single"; if (state.currentUpdateUrl) { state.form.action = new URL( state.currentUpdateUrl, @@ -5171,6 +5948,7 @@ async function openRowEditDialog(button, manager) { ); state.summary.hidden = true; state.summary.textContent = ""; + syncRowEditInsertModeUi(state); if (!state.dialog.open) { state.dialog.showModal(); @@ -5221,6 +5999,11 @@ function openRowInsertDialog(button, manager) { state.currentInsertUrl = tableInsertUrl(); state.currentUpdateUrl = null; state.currentFragmentUrl = null; + state.insertMode = "single"; + state.bulkInsertTextarea.value = ""; + state.shouldReloadOnClose = false; + state.redirectOnCloseUrl = null; + resetBulkInsertPreview(state); state.shouldRestoreFocus = true; state.hasLoaded = false; state.loadId += 1; @@ -5238,14 +6021,10 @@ function openRowInsertDialog(button, manager) { setRowEditDialogLoading(state, false); destroyRowEditFields(state); state.dialog.removeAttribute("aria-describedby"); - setRowDialogTitle( - state.title, - insertData.tableName - ? "Insert row into " + insertData.tableName - : "Insert row", - ); + setRowInsertDialogTitle(state); state.summary.hidden = true; state.summary.textContent = ""; + syncRowEditInsertModeUi(state); if (!state.dialog.open) { state.dialog.showModal(); diff --git a/datasette/views/table.py b/datasette/views/table.py index 1fc151e6..5335e6dc 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -517,6 +517,7 @@ async def _table_insert_ui( "tableName": table_name, "columns": columns, "primaryKeys": pks, + "maxInsertRows": datasette.setting("max_insert_rows"), } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index ee396de5..b0580d11 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -801,11 +801,77 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( @pytest.mark.playwright def test_insert_row_flow_uses_custom_column_field(page, datasette_server): + page.context.grant_permissions( + ["clipboard-read", "clipboard-write"], origin=datasette_server.rstrip("/") + ) page.goto(f"{datasette_server}data/projects") page.locator('button[data-table-action="insert-row"]').click() dialog = page.locator("#row-edit-dialog") dialog.wait_for() + bulk_insert = dialog.locator(".row-edit-bulk-insert") + bulk_insert.wait_for() + assert bulk_insert.inner_text() == "Insert multiple rows" + current_url = page.url + bulk_insert.click() + assert page.url == current_url + assert dialog.evaluate("node => node.open") + assert dialog.locator(".row-edit-fields").is_hidden() + assert dialog.locator(".row-edit-bulk").is_visible() + assert dialog.locator(".row-edit-save").inner_text() == "Preview rows" + assert ( + dialog.locator(".row-edit-bulk-note").inner_text() + == "Paste TSV, CSV, or JSON. You can also drop a text file onto this textarea." + ) + copy_template = dialog.locator(".row-edit-copy-template") + assert copy_template.inner_text() == "Copy spreadsheet template" + copy_template.click() + page.wait_for_function( + """() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """ + ) + assert page.evaluate("navigator.clipboard.readText()") == ( + "title\tmetadata\tlogo\tnotes\tscore" + ) + textarea = dialog.locator(".row-edit-bulk-textarea") + textarea.fill("title\tmetadata\nFrom TSV\t{}") + assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}" + dropped_value = textarea.evaluate("""node => new Promise((resolve) => { + node.addEventListener("input", () => resolve(node.value), { once: true }); + const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer })); + node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer })); + node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer })); + })""") + assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n" + dialog.locator(".row-edit-save").click() + assert dialog.evaluate("node => node.open") + assert dialog.locator(".row-edit-bulk-textarea").is_hidden() + assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows" + assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == ( + "Previewing 1 row." + ) + preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text() + assert "title" in preview_text + assert "metadata" in preview_text + assert "id" not in preview_text + assert "From CSV" in preview_text + assert "null" in preview_text + assert dialog.locator(".row-edit-cancel").inner_text() == "Back" + dialog.locator(".row-edit-cancel").click() + assert dialog.evaluate("node => node.open") + assert dialog.locator(".row-edit-bulk-textarea").is_visible() + assert dialog.locator(".row-edit-bulk-textarea").input_value() == ( + "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n" + ) + assert dialog.locator(".row-edit-save").inner_text() == "Preview rows" + single_insert = dialog.locator(".row-edit-single-insert") + assert single_insert.inner_text() == "Insert single row" + single_insert.click() + assert dialog.locator(".row-edit-bulk").is_hidden() + assert dialog.locator(".row-edit-fields").is_visible() + assert dialog.locator(".row-edit-save").inner_text() == "Insert row" dialog.locator('input[name="title"]').fill("Launch Datasette Cloud") dialog.locator('textarea[name="metadata"]').fill( '{"ok": false, "source": "playwright"}' @@ -835,6 +901,54 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server): assert data["score"] == 5 +@pytest.mark.playwright +def test_bulk_insert_preview_inserts_rows(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator('button[data-table-action="insert-row"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + dialog.locator(".row-edit-bulk-insert").click() + dialog.locator(".row-edit-bulk-textarea").fill( + "title\tmetadata\nBulk one\t{}\n\nBulk two\t{}" + ) + dialog.locator(".row-edit-save").click() + assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows" + dialog.locator(".row-edit-save").click() + dialog.locator( + ".row-edit-bulk-progress-status", has_text="2 rows inserted." + ).wait_for() + assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table" + dialog.locator(".row-edit-cancel").click() + page.wait_for_load_state("domcontentloaded") + assert page.url == f"{datasette_server}data/projects" + + assert project_rows(datasette_server, title="Bulk one") + assert project_rows(datasette_server, title="Bulk two") + + +@pytest.mark.playwright +def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator('button[data-table-action="insert-row"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + dialog.locator(".row-edit-bulk-insert").click() + dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree") + dialog.locator(".row-edit-save").click() + + assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows" + assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == ( + "Previewing 3 rows." + ) + preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text() + assert "one" in preview_text + assert "two" in preview_text + assert "three" in preview_text + assert "null" in preview_text + + @pytest.mark.playwright def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): page.goto(f"{datasette_server}data/projects") @@ -842,6 +956,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): dialog = page.locator("#row-edit-dialog") dialog.wait_for() + assert dialog.locator(".row-edit-bulk-insert").is_hidden() + assert dialog.locator(".row-edit-single-insert").is_hidden() + assert dialog.locator(".row-edit-bulk").is_hidden() title = dialog.locator('input[name="title"]') title.wait_for() title.fill("Build Datasette, edited") diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 3af2bb08..be569e68 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1316,6 +1316,7 @@ async def test_table_insert_action_button_and_data(): assert insert_data["path"] == "/data/items/-/insert" assert insert_data["tableName"] == "items" assert insert_data["primaryKeys"] == ["id"] + assert insert_data["maxInsertRows"] == 100 assert [column["name"] for column in insert_data["columns"]] == [ "name", "score", From 7ae601588e1259e0ed725d59f7f92b3f42c7a56e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 Jun 2026 22:32:47 -0700 Subject: [PATCH 02/31] Create table from data feature Similar to insert rows from JSON/CSV/TSV but it creates a brand new table instead. --- datasette/static/app.css | 123 ++++++ datasette/static/edit-tools.js | 757 +++++++++++++++++++++++++++++++-- tests/test_playwright.py | 83 +++- 3 files changed, 934 insertions(+), 29 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index ca51c099..91379dee 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2225,6 +2225,114 @@ select.table-create-input { gap: 10px; } +.table-create-columns[hidden], +.table-create-data[hidden], +.table-create-data-editor[hidden], +.table-create-data-preview[hidden] { + display: none; +} + +.table-create-data, +.table-create-data-editor { + display: grid; + gap: 8px; +} + +.table-create-data-label { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.table-create-data-textarea { + min-height: 16rem; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.table-create-data-textarea.table-create-data-drop-target { + border-color: var(--accent); + background: #f8fbff; + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-create-data-note { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +.table-create-data-preview { + display: grid; + gap: 10px; +} + +.table-create-data-preview-summary { + color: var(--ink); + font-size: 0.9rem; + font-weight: 600; + margin: 0; +} + +.table-create-data-pk-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: center; +} + +.table-create-data-preview-table-wrap { + border: 1px solid var(--rule); + border-radius: 5px; + max-height: 18rem; + overflow: auto; + background: #fff; +} + +.table-create-data-preview-table { + border-collapse: collapse; + font-size: 0.78rem; + min-width: 100%; + width: max-content; +} + +.table-create-data-preview-table th, +.table-create-data-preview-table td { + border-bottom: 1px solid var(--rule); + border-right: 1px solid var(--rule); + max-width: 18rem; + padding: 6px 8px; + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.table-create-data-preview-table th { + background: var(--paper); + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.table-create-data-preview-table tr:last-child td { + border-bottom: none; +} + +.table-create-data-preview-table th:last-child, +.table-create-data-preview-table td:last-child { + border-right: none; +} + +.table-create-data-preview-null { + color: var(--muted); + font-style: italic; +} + .table-create-column-list { display: grid; gap: 8px; @@ -2452,6 +2560,16 @@ select.table-create-input { background: var(--paper); } +.table-create-mode-link { + color: var(--accent); + font-size: 0.9rem; + margin-right: auto; +} + +.table-create-mode-link[hidden] { + display: none; +} + .table-create-dialog .btn { border: none; border-radius: 5px; @@ -3216,6 +3334,11 @@ select.table-alter-input { padding-top: 0; } + .table-create-data-pk-field { + grid-template-columns: 1fr; + gap: 5px; + } + .table-create-column-headings { display: none; } diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 58ddaf19..859bac0f 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -4,6 +4,7 @@ var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; var tableCreateDialogState = null; +var TABLE_CREATE_AUTOMATIC_PK = "__datasette_automatic_pk__"; var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; var tableAlterDialogState = null; @@ -810,13 +811,61 @@ async function loadTableCreateForeignKeyTargets(state) { ); } +function tableCreateIsDataMode(state) { + return state && state.mode === "data"; +} + +function tableCreateSaveButtonText(state) { + if (tableCreateIsDataMode(state)) { + return state.dataPreviewReady ? "Create table" : "Preview rows"; + } + return "Create table"; +} + +function syncTableCreateModeUi(state) { + if (!state) { + return; + } + var isDataMode = tableCreateIsDataMode(state); + state.columnsPanel.hidden = isDataMode; + state.dataPanel.hidden = !isDataMode; + state.dataEditor.hidden = !isDataMode || state.dataPreviewReady; + state.dataPreview.hidden = !isDataMode || !state.dataPreviewReady; + state.createFromDataLink.hidden = isDataMode; + state.manualCreateLink.hidden = !isDataMode; +} + +function updateTableCreateDialogButtons(state) { + if (!state) { + return; + } + syncTableCreateModeUi(state); + state.cancelButton.disabled = state.isSaving; + state.saveButton.disabled = state.isSaving; + state.addColumnButton.disabled = state.isSaving; + state.cancelButton.textContent = + tableCreateIsDataMode(state) && state.dataPreviewReady ? "Back" : "Cancel"; + state.saveButton.textContent = state.isSaving + ? "Creating..." + : tableCreateSaveButtonText(state); +} + function tableCreateDialogSignature(state) { if (!state || !state.form) { return ""; } - return JSON.stringify({ + var signature = { + mode: state.mode || "manual", table: state.tableName.value, - columns: tableCreateDialogRows(state).map(function (row) { + data: state.dataTextarea ? state.dataTextarea.value : "", + dataPrimaryKey: state.dataPkSelect + ? state.dataPkSelect.value + : TABLE_CREATE_AUTOMATIC_PK, + }; + if (tableCreateIsDataMode(state)) { + signature.previewReady = !!state.dataPreviewReady; + } else { + signature.columns = tableCreateDialogRows(state).map(function (row) { return { name: row.querySelector(".table-create-column-name").value, type: row.querySelector(".table-create-column-type").value, @@ -837,8 +886,9 @@ function tableCreateDialogSignature(state) { } ).value || "", }; - }), - }); + }); + } + return JSON.stringify(signature); } function tableCreateDialogHasChanges(state) { @@ -864,18 +914,23 @@ function showTableCreateDialogError(state, message) { 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; }); + state.fields + .querySelectorAll( + ".table-create-data input, .table-create-data select, .table-create-data textarea, .table-create-data button", + ) + .forEach(function (control) { + control.disabled = isSaving; + }); + state.tableName.disabled = isSaving; if (!isSaving) { updateTableCreateColumnRules(state); } + updateTableCreateDialogButtons(state); updateTableCreateMoveButtons(state); } @@ -1231,8 +1286,11 @@ function addTableCreateColumn(state, column) { } function resetTableCreateDialog(state) { + state.mode = "manual"; state.nextColumnIndex = 0; state.tableName.value = ""; + state.dataTextarea.value = ""; + resetTableCreateDataPreview(state); state.columnList.textContent = ""; addTableCreateColumn(state, { name: "id", @@ -1245,9 +1303,39 @@ function resetTableCreateDialog(state) { primaryKey: false, }); updateTableCreateColumnRules(state); + updateTableCreateDialogButtons(state); state.initialSignature = tableCreateDialogSignature(state); } +function showTableCreateDataMode(state) { + if (!state || state.isSaving) { + return; + } + state.mode = "data"; + clearTableCreateDialogError(state); + updateTableCreateDialogButtons(state); + if (state.dataPreviewReady && state.dataPkSelect) { + state.dataPkSelect.focus(); + } else { + state.dataTextarea.focus(); + } +} + +function showTableCreateManualMode(state) { + if (!state || state.isSaving) { + return; + } + state.mode = "manual"; + clearTableCreateDialogError(state); + updateTableCreateDialogButtons(state); + var firstInput = state.columnList.querySelector(".table-create-column-name"); + if (firstInput) { + firstInput.focus(); + } else { + state.tableName.focus(); + } +} + function collectTableCreatePayload(state) { var payload = { table: state.tableName.value.trim(), @@ -1315,14 +1403,9 @@ function collectTableCreateColumnTypeAssignments(state) { } 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_."; + var tableNameError = validateTableCreateTableName(payload.table); + if (tableNameError) { + return tableNameError; } if (!payload.columns.length) { return "At least one column is required."; @@ -1352,6 +1435,19 @@ function validateTableCreatePayload(payload) { return null; } +function validateTableCreateTableName(tableName) { + if (!tableName) { + return "Table name is required."; + } + if (tableName.indexOf("\n") !== -1) { + return "Table name cannot contain newlines."; + } + if (/^sqlite_/i.test(tableName)) { + return "Table name cannot start with sqlite_."; + } + return null; +} + function validateTableCreateColumnTypeAssignments(assignments) { for (var i = 0; i < assignments.length; i += 1) { var assignment = assignments[i]; @@ -1374,6 +1470,446 @@ function validateTableCreateColumnTypeAssignments(assignments) { return null; } +function normalizeCreateTableDataJsonValue(value) { + if (typeof value === "undefined") { + return null; + } + if (Array.isArray(value) || (value && typeof value === "object")) { + return JSON.stringify(value); + } + return value; +} + +function createTableDataColumnObjects(names) { + return names.map(function (name) { + return { name: name }; + }); +} + +function validateCreateTableDataHeaders(headers) { + if (!headers.length) { + throw new Error("No columns found to preview."); + } + var seen = {}; + headers.forEach(function (name, index) { + if (!name) { + throw new Error("Column header " + (index + 1) + " is blank."); + } + if (name.indexOf("\n") !== -1) { + throw new Error("Column names cannot contain newlines."); + } + var key = name.toLowerCase(); + if (seen[key]) { + throw new Error("Duplicate column name: " + name); + } + seen[key] = true; + }); +} + +function jsonRowIsObject(item) { + return !!(item && typeof item === "object" && !Array.isArray(item)); +} + +function extractJsonObjectRows(parsed) { + if (Array.isArray(parsed)) { + return parsed; + } + if (!jsonRowIsObject(parsed)) { + throw new Error( + "JSON must be an array of objects, or an object containing an array of objects.", + ); + } + + var bestRows = null; + Object.keys(parsed).forEach(function (key) { + var value = parsed[key]; + if (!Array.isArray(value) || !value.every(jsonRowIsObject)) { + return; + } + if (!bestRows || value.length > bestRows.length) { + bestRows = value; + } + }); + if (!bestRows) { + throw new Error( + "JSON object must contain at least one root key with an array of objects.", + ); + } + return bestRows; +} + +function parseJsonObjectRows(text) { + var parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error("Invalid JSON: " + error.message); + } + var rows = extractJsonObjectRows(parsed); + if (!rows.length) { + throw new Error("No rows found to preview."); + } + return rows; +} + +function parseJsonCreateTableRows(text) { + var parsed = parseJsonObjectRows(text); + + var columnNames = []; + var columnMap = {}; + parsed.forEach(function (item, index) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error("JSON row " + (index + 1) + " must be an object."); + } + Object.keys(item).forEach(function (name) { + if (!columnMap[name]) { + columnMap[name] = true; + columnNames.push(name); + } + }); + }); + validateCreateTableDataHeaders(columnNames); + + var rows = parsed.map(function (item) { + var row = {}; + columnNames.forEach(function (name) { + row[name] = Object.prototype.hasOwnProperty.call(item, name) + ? normalizeCreateTableDataJsonValue(item[name]) + : null; + }); + return row; + }); + return { + columns: createTableDataColumnObjects(columnNames), + rows: rows, + }; +} + +function createTableDelimitedValueIsInteger(value) { + return /^[-+]?\d+$/.test(String(value).trim()); +} + +function createTableDelimitedValueIsFloat(value) { + var trimmed = String(value).trim(); + if (!trimmed) { + return false; + } + var numberValue = Number(trimmed); + return Number.isFinite(numberValue); +} + +function inferCreateTableDelimitedColumnType(values) { + var nonBlankValues = values.filter(function (value) { + return String(value).trim() !== ""; + }); + if (!nonBlankValues.length) { + return "text"; + } + if (nonBlankValues.every(createTableDelimitedValueIsInteger)) { + return "integer"; + } + if (nonBlankValues.every(createTableDelimitedValueIsFloat)) { + return "float"; + } + return "text"; +} + +function coerceCreateTableDelimitedValue(value, type) { + var trimmed = String(value).trim(); + if (trimmed === "") { + return ""; + } + if (type === "integer") { + return parseInt(trimmed, 10); + } + if (type === "float") { + return Number(trimmed); + } + return value; +} + +function detectCreateTableDataDelimiter(text) { + var firstLine = + text.split(/\r\n|\n|\r/).find(function (line) { + return line.trim() !== ""; + }) || ""; + var csvRows = delimiterPreviewRows(firstLine, ","); + var tsvRows = delimiterPreviewRows(firstLine, "\t"); + var csvColumns = csvRows.length ? csvRows[0].length : 0; + var tsvColumns = tsvRows.length ? tsvRows[0].length : 0; + + if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) { + return "\t"; + } + if (tsvColumns > csvColumns) { + return "\t"; + } + if (csvColumns > 1) { + return ","; + } + if (tsvColumns > 1) { + return "\t"; + } + return null; +} + +function parseDelimitedCreateTableRows(text) { + var delimiter = detectCreateTableDataDelimiter(text); + var rows = ( + delimiter === null + ? splitSingleColumnRows(text) + : splitDelimitedRows(text, delimiter) + ).filter(function (row) { + return !bulkInsertDelimitedRowIsBlank(row); + }); + if (!rows.length) { + throw new Error("No rows found to preview."); + } + + var headers = rows[0].map(function (value) { + return value.trim(); + }); + validateCreateTableDataHeaders(headers); + var dataRows = rows.slice(1); + if (!dataRows.length) { + throw new Error("No data rows found to preview."); + } + + dataRows.forEach(function (row, index) { + if (row.length > headers.length) { + throw new Error( + "Row " + + (index + 1) + + " has " + + row.length + + " values, but only " + + headers.length + + " columns were provided.", + ); + } + }); + + var columnTypes = headers.map(function (_name, columnIndex) { + return inferCreateTableDelimitedColumnType( + dataRows.map(function (row) { + return row[columnIndex] || ""; + }), + ); + }); + + return { + columns: createTableDataColumnObjects(headers), + rows: dataRows.map(function (row) { + var rowObject = {}; + headers.forEach(function (name, columnIndex) { + rowObject[name] = coerceCreateTableDelimitedValue( + row[columnIndex] || "", + columnTypes[columnIndex], + ); + }); + return rowObject; + }), + }; +} + +function parseCreateTableDataRows(text) { + var trimmed = text.trim(); + if (!trimmed) { + throw new Error("Paste rows before previewing."); + } + if (trimmed[0] === "[" || trimmed[0] === "{") { + return parseJsonCreateTableRows(trimmed); + } + return parseDelimitedCreateTableRows(trimmed); +} + +function tableCreateDataRecommendedPrimaryKey(columns, rows) { + var candidates = []; + columns.forEach(function (column, columnIndex) { + var distinctValues = {}; + var maxLength = 0; + var valid = rows.length > 0; + rows.forEach(function (row) { + if (!valid) { + return; + } + var value = row[column.name]; + if (value === null || typeof value === "undefined") { + valid = false; + return; + } + var text = String(value).trim(); + if (!text || text.length >= 20 || /\s/.test(text)) { + valid = false; + return; + } + if (distinctValues[text]) { + valid = false; + return; + } + distinctValues[text] = true; + maxLength = Math.max(maxLength, text.length); + }); + if (valid) { + candidates.push({ + name: column.name, + maxLength: maxLength, + columnIndex: columnIndex, + }); + } + }); + candidates.sort(function (left, right) { + if (left.maxLength !== right.maxLength) { + return left.maxLength - right.maxLength; + } + return left.columnIndex - right.columnIndex; + }); + return candidates.length ? candidates[0].name : TABLE_CREATE_AUTOMATIC_PK; +} + +function renderTableCreateDataPreview(state, preview) { + state.dataPreview.textContent = ""; + + var summary = document.createElement("p"); + summary.className = "table-create-data-preview-summary"; + summary.textContent = + "Previewing " + + preview.rows.length + + " row" + + (preview.rows.length === 1 ? "." : "s."); + state.dataPreview.appendChild(summary); + + var pkField = document.createElement("div"); + pkField.className = "table-create-data-pk-field"; + var pkLabel = document.createElement("label"); + pkLabel.className = "table-create-data-label"; + pkLabel.setAttribute("for", "table-create-data-primary-key"); + pkLabel.textContent = "Primary key"; + var pkSelect = document.createElement("select"); + pkSelect.id = "table-create-data-primary-key"; + pkSelect.className = "table-create-input table-create-data-primary-key"; + var automaticOption = document.createElement("option"); + automaticOption.value = TABLE_CREATE_AUTOMATIC_PK; + automaticOption.textContent = "Automatic ID column"; + pkSelect.appendChild(automaticOption); + preview.columns.forEach(function (column) { + var option = document.createElement("option"); + option.value = column.name; + option.textContent = column.name; + pkSelect.appendChild(option); + }); + pkSelect.value = tableCreateDataRecommendedPrimaryKey( + preview.columns, + preview.rows, + ); + pkSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + }); + state.dataPkSelect = pkSelect; + pkField.appendChild(pkLabel); + pkField.appendChild(pkSelect); + state.dataPreview.appendChild(pkField); + + var tableWrap = document.createElement("div"); + tableWrap.className = "table-create-data-preview-table-wrap"; + var table = document.createElement("table"); + table.className = "table-create-data-preview-table"; + var thead = document.createElement("thead"); + var headerRow = document.createElement("tr"); + preview.columns.forEach(function (column) { + var th = document.createElement("th"); + th.scope = "col"; + th.textContent = column.name; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + var tbody = document.createElement("tbody"); + preview.rows.forEach(function (row) { + var tr = document.createElement("tr"); + preview.columns.forEach(function (column) { + var td = document.createElement("td"); + var value = row[column.name]; + td.textContent = bulkInsertPreviewValue(value); + if (value === null) { + td.className = "table-create-data-preview-null"; + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + tableWrap.appendChild(table); + state.dataPreview.appendChild(tableWrap); + state.dataPreview.hidden = false; +} + +function resetTableCreateDataPreview(state) { + state.dataPreviewRows = null; + state.dataPreviewColumns = []; + state.dataPreviewReady = false; + state.dataPkSelect = null; + state.dataPreview.hidden = true; + state.dataPreview.textContent = ""; + syncTableCreateModeUi(state); +} + +function previewTableCreateDataRows(state) { + clearTableCreateDialogError(state); + resetTableCreateDataPreview(state); + try { + var preview = parseCreateTableDataRows(state.dataTextarea.value); + state.dataPreviewRows = preview.rows; + state.dataPreviewColumns = preview.columns; + state.dataPreviewReady = true; + renderTableCreateDataPreview(state, preview); + updateTableCreateDialogButtons(state); + } catch (error) { + showTableCreateDialogError( + state, + error.message || "Could not preview rows.", + ); + updateTableCreateDialogButtons(state); + } +} + +function collectTableCreateDataPayload(state) { + var payload = { + table: state.tableName.value.trim(), + rows: state.dataPreviewRows || [], + }; + var primaryKey = state.dataPkSelect + ? state.dataPkSelect.value + : TABLE_CREATE_AUTOMATIC_PK; + payload.pk = + primaryKey === TABLE_CREATE_AUTOMATIC_PK ? "id" : primaryKey || undefined; + return payload; +} + +function validateTableCreateDataPayload(payload, state) { + var tableNameError = validateTableCreateTableName(payload.table); + if (tableNameError) { + return tableNameError; + } + if (!payload.rows.length) { + return "No rows found to create."; + } + if ( + state.dataPkSelect && + state.dataPkSelect.value === TABLE_CREATE_AUTOMATIC_PK && + payload.rows.some(function (row) { + return Object.prototype.hasOwnProperty.call(row, "id"); + }) + ) { + return ( + "Automatic ID column cannot be used because the pasted data " + + "already has an id column." + ); + } + return null; +} + function fallbackTableUrl(tableName) { var data = databaseCreateTableData() || {}; if (!data.path) { @@ -1441,6 +1977,57 @@ async function assignTableCreateColumnTypes( } } +async function createTableFromDataPreview(state) { + var data = databaseCreateTableData(); + if (!data || !data.path) { + showTableCreateDialogError(state, "Could not find the create table URL."); + return; + } + var payload = collectTableCreateDataPayload(state); + var validationError = validateTableCreateDataPayload(payload, state); + if (validationError) { + showTableCreateDialogError(state, validationError); + return; + } + clearTableCreateDialogError(state); + 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); + } + 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", + ); + } +} + async function saveTableCreateDialog(state) { if (state.isSaving) { return; @@ -1450,6 +2037,14 @@ async function saveTableCreateDialog(state) { showTableCreateDialogError(state, "Could not find the create table URL."); return; } + if (tableCreateIsDataMode(state)) { + if (!state.dataPreviewReady) { + previewTableCreateDataRows(state); + } else { + await createTableFromDataPreview(state); + } + return; + } clearTableCreateDialogError(state); var payload = collectTableCreatePayload(state); var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state); @@ -1560,8 +2155,18 @@ function ensureTableCreateDialog(manager) {
+ @@ -1576,13 +2181,25 @@ function ensureTableCreateDialog(manager) { error: dialog.querySelector(".table-create-error"), fields: dialog.querySelector(".table-create-fields"), tableName: dialog.querySelector(".table-create-table-name"), + columnsPanel: dialog.querySelector(".table-create-columns"), columnList: dialog.querySelector(".table-create-column-list"), addColumnButton: dialog.querySelector(".table-create-add-column"), + dataPanel: dialog.querySelector(".table-create-data"), + dataEditor: dialog.querySelector(".table-create-data-editor"), + dataTextarea: dialog.querySelector(".table-create-data-textarea"), + dataPreview: dialog.querySelector(".table-create-data-preview"), + createFromDataLink: dialog.querySelector(".table-create-from-data"), + manualCreateLink: dialog.querySelector(".table-create-manual"), cancelButton: dialog.querySelector(".table-create-cancel"), saveButton: dialog.querySelector(".table-create-save"), currentButton: null, shouldRestoreFocus: true, isSaving: false, + mode: "manual", + dataPreviewRows: null, + dataPreviewColumns: [], + dataPreviewReady: false, + dataPkSelect: null, initialSignature: "", nextColumnIndex: 0, foreignKeyTargets: [], @@ -1606,13 +2223,108 @@ function ensureTableCreateDialog(manager) { }); tableCreateDialogState.cancelButton.addEventListener("click", function () { + if ( + tableCreateIsDataMode(tableCreateDialogState) && + tableCreateDialogState.dataPreviewReady && + !tableCreateDialogState.isSaving + ) { + resetTableCreateDataPreview(tableCreateDialogState); + updateTableCreateDialogButtons(tableCreateDialogState); + tableCreateDialogState.dataTextarea.focus(); + return; + } closeTableCreateDialogIfConfirmed(tableCreateDialogState); }); + tableCreateDialogState.createFromDataLink.addEventListener( + "click", + function (ev) { + ev.preventDefault(); + showTableCreateDataMode(tableCreateDialogState); + }, + ); + + tableCreateDialogState.manualCreateLink.addEventListener( + "click", + function (ev) { + ev.preventDefault(); + showTableCreateManualMode(tableCreateDialogState); + }, + ); + tableCreateDialogState.tableName.addEventListener("input", function () { clearTableCreateDialogError(tableCreateDialogState); }); + tableCreateDialogState.dataTextarea.addEventListener( + "dragenter", + function (ev) { + ev.preventDefault(); + tableCreateDialogState.dataTextarea.classList.add( + "table-create-data-drop-target", + ); + }, + ); + + tableCreateDialogState.dataTextarea.addEventListener( + "dragover", + function (ev) { + ev.preventDefault(); + tableCreateDialogState.dataTextarea.classList.add( + "table-create-data-drop-target", + ); + }, + ); + + tableCreateDialogState.dataTextarea.addEventListener( + "dragleave", + function () { + tableCreateDialogState.dataTextarea.classList.remove( + "table-create-data-drop-target", + ); + }, + ); + + tableCreateDialogState.dataTextarea.addEventListener( + "drop", + async function (ev) { + ev.preventDefault(); + tableCreateDialogState.dataTextarea.classList.remove( + "table-create-data-drop-target", + ); + var files = ev.dataTransfer && ev.dataTransfer.files; + if (!files || !files.length) { + return; + } + try { + tableCreateDialogState.dataTextarea.value = await readTextFile( + files[0], + ); + tableCreateDialogState.dataTextarea.dispatchEvent( + new Event("input", { bubbles: true }), + ); + clearTableCreateDialogError(tableCreateDialogState); + } catch (_error) { + showTableCreateDialogError( + tableCreateDialogState, + "Could not read that text file.", + ); + } + }, + ); + + tableCreateDialogState.dataTextarea.addEventListener("dragend", function () { + tableCreateDialogState.dataTextarea.classList.remove( + "table-create-data-drop-target", + ); + }); + + tableCreateDialogState.dataTextarea.addEventListener("input", function () { + resetTableCreateDataPreview(tableCreateDialogState); + clearTableCreateDialogError(tableCreateDialogState); + updateTableCreateDialogButtons(tableCreateDialogState); + }); + dialog.addEventListener("click", function (ev) { if (ev.target === dialog) { closeTableCreateDialogIfConfirmed(tableCreateDialogState); @@ -4958,18 +5670,7 @@ function bulkInsertColumnMap(columns) { } function parseJsonBulkInsertRows(text, columns) { - var parsed; - try { - parsed = JSON.parse(text); - } catch (error) { - throw new Error("Invalid JSON: " + error.message); - } - if (!Array.isArray(parsed)) { - throw new Error("JSON must be an array of objects."); - } - if (!parsed.length) { - throw new Error("No rows found to preview."); - } + var parsed = parseJsonObjectRows(text); var columnMap = bulkInsertColumnMap(columns); return parsed.map(function (item, index) { diff --git a/tests/test_playwright.py b/tests/test_playwright.py index b0580d11..7e475738 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -123,6 +123,7 @@ def write_playwright_config(config_path): "data": { "permissions": { "create-table": True, + "insert-row": True, "set-column-type": True, }, "tables": { @@ -381,6 +382,78 @@ def test_create_table_flow(page, datasette_server): assert "NOT NULL DEFAULT 'Untitled'" in schema +@pytest.mark.playwright +def test_create_table_from_data_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() + from_data = dialog.locator(".table-create-from-data") + assert from_data.inner_text() == "Create table from data" + from_data.click() + + assert dialog.locator(".table-create-columns").is_hidden() + assert dialog.locator(".table-create-data").is_visible() + assert dialog.locator(".table-create-save").inner_text() == "Preview rows" + assert ( + dialog.locator(".table-create-data-note").inner_text() + == "Paste TSV, CSV, or JSON. You can also drop a text file onto this textarea." + ) + assert dialog.locator(".table-create-manual").inner_text() == ( + "Create table manually" + ) + + dialog.locator(".table-create-table-name").fill("playwright_from_data") + textarea = dialog.locator(".table-create-data-textarea") + textarea.fill( + json.dumps( + { + "metadata": [{"short_id": "a", "name": "Ignored", "score": 9}], + "people": [ + {"short_id": "b", "name": "Ada", "score": 1}, + {"short_id": "c", "name": "Bob", "score": 2.5}, + ], + } + ) + ) + dialog.locator(".table-create-save").click() + + assert dialog.locator(".table-create-data-textarea").is_hidden() + assert dialog.locator(".table-create-save").inner_text() == "Create table" + assert dialog.locator(".table-create-cancel").inner_text() == "Back" + assert dialog.locator(".table-create-data-preview-summary").inner_text() == ( + "Previewing 2 rows." + ) + assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id" + preview_text = dialog.locator(".table-create-data-preview-table").inner_text() + assert "short_id" in preview_text + assert "Ada" in preview_text + assert "2.5" in preview_text + + dialog.locator(".table-create-cancel").click() + assert dialog.evaluate("node => node.open") + assert dialog.locator(".table-create-data-textarea").is_visible() + assert '"people"' in dialog.locator(".table-create-data-textarea").input_value() + assert dialog.locator(".table-create-save").inner_text() == "Preview rows" + + dialog.locator(".table-create-save").click() + assert dialog.locator(".table-create-save").inner_text() == "Create table" + dialog.locator(".table-create-save").click() + page.wait_for_url("**/data/playwright_from_data") + + response = httpx.get( + f"{datasette_server}data/playwright_from_data.json?_shape=objects" + ) + response.raise_for_status() + data = response.json() + assert data["rows"] == [ + {"short_id": "b", "name": "Ada", "score": 1}, + {"short_id": "c", "name": "Bob", "score": 2.5}, + ] + + @pytest.mark.playwright def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server): page.goto(f"{datasette_server}data") @@ -910,7 +983,15 @@ def test_bulk_insert_preview_inserts_rows(page, datasette_server): dialog.wait_for() dialog.locator(".row-edit-bulk-insert").click() dialog.locator(".row-edit-bulk-textarea").fill( - "title\tmetadata\nBulk one\t{}\n\nBulk two\t{}" + json.dumps( + { + "metadata": [{"title": "Ignored", "metadata": "{}"}], + "projects": [ + {"title": "Bulk one", "metadata": "{}"}, + {"title": "Bulk two", "metadata": "{}"}, + ], + } + ) ) dialog.locator(".row-edit-save").click() assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows" From cb622a3dd671c840279f9408f430da703464d218 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 Jun 2026 22:35:17 -0700 Subject: [PATCH 03/31] Switching to create table from data tab no longer marks as dirty I was getting a confirm() dialog when I clicked outside the dialog on the create table from data page despite not having filled out any fields. --- datasette/static/edit-tools.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 859bac0f..12ddb842 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -855,17 +855,12 @@ function tableCreateDialogSignature(state) { return ""; } var signature = { - mode: state.mode || "manual", table: state.tableName.value, data: state.dataTextarea ? state.dataTextarea.value : "", dataPrimaryKey: state.dataPkSelect ? state.dataPkSelect.value : TABLE_CREATE_AUTOMATIC_PK, - }; - if (tableCreateIsDataMode(state)) { - signature.previewReady = !!state.dataPreviewReady; - } else { - signature.columns = tableCreateDialogRows(state).map(function (row) { + columns: tableCreateDialogRows(state).map(function (row) { return { name: row.querySelector(".table-create-column-name").value, type: row.querySelector(".table-create-column-type").value, @@ -886,8 +881,8 @@ function tableCreateDialogSignature(state) { } ).value || "", }; - }); - } + }), + }; return JSON.stringify(signature); } From e0a138ffbd8442ac693d3fa970380b2db36a57bd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 29 Jun 2026 15:16:33 -0700 Subject: [PATCH 04/31] Tweaks to bulp insert screen Moved text around, added clickable 'open a file' button for mobile. Screenshot: https://github.com/simonw/datasette/pull/2813#issuecomment-4837736664 --- datasette/static/app.css | 46 +++++++++++++++++--- datasette/static/edit-tools.js | 79 +++++++++++++++++++++++++--------- tests/test_playwright.py | 24 ++++++++++- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 91379dee..b430e208 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1819,15 +1819,12 @@ textarea.row-edit-input { display: none; } -.row-edit-bulk-label { - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; -} - .row-edit-bulk-actions { display: flex; - justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-start; } .row-edit-bulk-actions .btn { @@ -1835,6 +1832,19 @@ textarea.row-edit-input { padding-right: 12px; } +.row-edit-copy-template-label-narrow { + display: none; +} + +.row-edit-bulk-template-note { + color: var(--muted); + font-size: 0.82rem; +} + +.row-edit-bulk-template-note-narrow { + display: none; +} + .row-edit-bulk-textarea { min-height: 16rem; resize: vertical; @@ -1855,6 +1865,28 @@ textarea.row-edit-input { margin: 0; } +.row-edit-bulk-note .button-as-link { + font: inherit; +} + +@media (max-width: 640px) { + .row-edit-copy-template-label-wide { + display: none; + } + + .row-edit-copy-template-label-narrow { + display: inline; + } + + .row-edit-bulk-template-note-wide { + display: none; + } + + .row-edit-bulk-template-note-narrow { + display: inline; + } +} + .row-edit-bulk-preview { display: grid; gap: 8px; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 12ddb842..0415d772 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -5474,6 +5474,22 @@ function readTextFile(file) { }); } +async function loadBulkInsertTextFile(state, file) { + if (!file) { + return; + } + try { + state.bulkInsertTextarea.value = await readTextFile(file); + state.bulkInsertTextarea.dispatchEvent( + new Event("input", { bubbles: true }), + ); + clearRowEditDialogError(state); + state.bulkInsertTextarea.focus(); + } catch (_error) { + showRowEditDialogError(state, "Could not read that text file."); + } +} + function bulkInsertTemplateText(state) { return (state.bulkInsertColumns || []).join("\t"); } @@ -5498,11 +5514,23 @@ async function copyTextToClipboard(text) { } } +function setBulkInsertCopyButtonReady(state) { + state.copyTemplateButton.textContent = ""; + var wideLabel = document.createElement("span"); + wideLabel.className = "row-edit-copy-template-label-wide"; + wideLabel.textContent = "Copy spreadsheet template"; + state.copyTemplateButton.appendChild(wideLabel); + var narrowLabel = document.createElement("span"); + narrowLabel.className = "row-edit-copy-template-label-narrow"; + narrowLabel.textContent = "Copy template"; + state.copyTemplateButton.appendChild(narrowLabel); +} + function setBulkInsertCopyButtonCopied(state) { state.copyTemplateButton.textContent = "Copied"; clearTimeout(state.copyTemplateResetTimer); state.copyTemplateResetTimer = setTimeout(function () { - state.copyTemplateButton.textContent = "Copy spreadsheet template"; + setBulkInsertCopyButtonReady(state); }, 1500); } @@ -6255,7 +6283,7 @@ function renderRowInsertFields(state, data) { return column.name; }); state.copyTemplateButton.disabled = !state.bulkInsertColumns.length; - state.copyTemplateButton.textContent = "Copy spreadsheet template"; + setBulkInsertCopyButtonReady(state); clearTimeout(state.copyTemplateResetTimer); state.copyTemplateResetTimer = null; resetBulkInsertPreview(state); @@ -6350,12 +6378,13 @@ function ensureRowEditDialog(manager) {