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.
This commit is contained in:
Simon Willison 2026-06-25 22:02:39 -07:00
commit b7a896a803
5 changed files with 1062 additions and 10 deletions

View file

@ -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;
}

View file

@ -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) {
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
<div class="row-edit-fields"></div>
<div class="row-edit-bulk" hidden>
<div class="row-edit-bulk-editor">
<label class="row-edit-bulk-label" for="row-edit-bulk-textarea">Rows to insert</label>
<div class="row-edit-bulk-actions">
<button type="button" class="btn btn-ghost row-edit-copy-template">Copy spreadsheet template</button>
</div>
<textarea class="row-edit-input row-edit-bulk-textarea" id="row-edit-bulk-textarea" name="_bulk_rows" rows="12" spellcheck="false"></textarea>
<p class="row-edit-bulk-note">Paste TSV, CSV, or JSON. You can also drop a text file onto this textarea.</p>
</div>
<div class="row-edit-bulk-preview" hidden></div>
<div class="row-edit-bulk-progress" hidden>
<progress class="row-edit-bulk-progress-bar" value="0" max="1"></progress>
<p class="row-edit-bulk-progress-status" role="status" aria-live="polite"></p>
</div>
</div>
<div class="modal-footer">
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
</div>
@ -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();

View file

@ -517,6 +517,7 @@ async def _table_insert_ui(
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
"maxInsertRows": datasette.setting("max_insert_rows"),
}

View file

@ -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")

View file

@ -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",