Compare commits

...

4 commits

Author SHA1 Message Date
Simon Willison
693f4ed601 Show insert message in correct place, 1s inactive delay
Since I want this to be usable by untrusted datasette-apps it is important that
they cannot show the dialog in a way that tricks the user into clicking insert.

So a 1s delay on making that button active.
2026-06-20 12:23:52 -07:00
Simon Willison
76e6a3bc38 New .insertDialog() JavaScript method
Anywhere in Datasette can now open an insert dialog
pre-populated with suggested data.

Using it in one place already, on the row page
to provide an insert button for related rows.
2026-06-19 23:40:15 -07:00
Simon Willison
f3fa6ecf61 Render error pages using datasette.render_template()
Avoids risk of important variables not being present.
2026-06-19 23:38:50 -07:00
Simon Willison
a76646e3fd GET /db/table/-/insert returns data needed for dialog
As part of making the insert dialog less dependent on the table page.
2026-06-19 22:51:21 -07:00
14 changed files with 1125 additions and 83 deletions

View file

@ -59,16 +59,12 @@ def handle_exception(datasette, request, exception):
if request.path.split("?")[0].endswith(".json"): if request.path.split("?")[0].endswith(".json"):
return Response.json(info, status=status, headers=headers) return Response.json(info, status=status, headers=headers)
else: else:
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html( return Response.html(
await template.render_async( await datasette.render_template(
dict( templates,
info, info,
urls=datasette.urls, request=request,
app_css_hash=datasette.app_css_hash(), view_name="error",
menu_links=lambda: [],
)
), ),
status=status, status=status,
headers=headers, headers=headers,

View file

@ -1224,6 +1224,25 @@ button.table-insert-row svg {
flex-shrink: 0; flex-shrink: 0;
} }
ul.row-foreign-key-tables {
display: grid;
gap: 0.35rem;
}
ul.row-foreign-key-tables li {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
button.core.row-foreign-key-insert {
margin-left: 0;
padding: 0.15rem 0.45rem;
font-size: 0.78rem;
line-height: 1.15;
}
dialog.row-delete-dialog { dialog.row-delete-dialog {
--ink: #0f0f0f; --ink: #0f0f0f;
--paper: #eef6ff; --paper: #eef6ff;

View file

@ -25,6 +25,29 @@ const DOM_SELECTORS = {
facetResults: ".facet-results [data-column]", facetResults: ".facet-results [data-column]",
}; };
let editToolsPromise = null;
let autocompletePromise = null;
function datasettePath(path) {
const baseUrl = window.datasetteBaseUrl || "/";
return baseUrl.replace(/\/?$/, "/") + String(path || "").replace(/^\/+/, "");
}
function parseInsertDialogRow(value) {
if (!value) {
return {};
}
try {
const row = JSON.parse(value);
if (!row || typeof row !== "object" || Array.isArray(row)) {
throw new Error("row must be an object");
}
return row;
} catch (error) {
throw new Error("Invalid insert dialog row: " + error.message);
}
}
/** /**
* Monolith class for interacting with Datasette JS API * Monolith class for interacting with Datasette JS API
* Imported with DEFER, runs after main document parsed * Imported with DEFER, runs after main document parsed
@ -39,6 +62,62 @@ const datasetteManager = {
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions? // Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
plugins: new Map(), plugins: new Map(),
path: datasettePath,
loadAutocomplete: () => {
if (!window.customElements) {
return Promise.resolve(null);
}
if (customElements.get("datasette-autocomplete")) {
return Promise.resolve(customElements.get("datasette-autocomplete"));
}
if (!autocompletePromise) {
const url =
window.datasetteAutocompleteUrl ||
datasettePath("/-/static/autocomplete.js");
autocompletePromise = import(url).then(() =>
customElements.get("datasette-autocomplete"),
);
}
return autocompletePromise;
},
loadEditTools: () => {
if (window.__DATASETTE_EDIT_TOOLS__) {
if (window.__DATASETTE_EDIT_TOOLS__.install) {
window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager);
}
return Promise.resolve(window.__DATASETTE_EDIT_TOOLS__);
}
if (!editToolsPromise) {
const url =
window.datasetteEditToolsUrl ||
datasettePath("/-/static/edit-tools.js");
editToolsPromise = import(url).then(() => {
if (!window.__DATASETTE_EDIT_TOOLS__) {
throw new Error("edit-tools.js did not register its API");
}
if (window.__DATASETTE_EDIT_TOOLS__.install) {
window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager);
}
return window.__DATASETTE_EDIT_TOOLS__;
});
}
return editToolsPromise;
},
insertDialog: async (database, table, row, message, options) => {
const editTools = await datasetteManager.loadEditTools();
return editTools.insertDialog(
datasetteManager,
database,
table,
row,
message,
options,
);
},
registerPlugin: (name, pluginMetadata) => { registerPlugin: (name, pluginMetadata) => {
if (datasetteManager.plugins.has(name)) { if (datasetteManager.plugins.has(name)) {
console.warn(`Warning -> plugin ${name} was redefined`); console.warn(`Warning -> plugin ${name} was redefined`);
@ -230,9 +309,7 @@ const datasetteManager = {
}; };
const initializeDatasette = () => { const initializeDatasette = () => {
// Hide the global behind __ prefix. Ideally they should be listening for the window.datasette = datasetteManager;
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
window.__DATASETTE__ = datasetteManager; window.__DATASETTE__ = datasetteManager;
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, { const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
@ -240,6 +317,43 @@ const initializeDatasette = () => {
}); });
document.dispatchEvent(initDatasetteEvent); document.dispatchEvent(initDatasetteEvent);
document.addEventListener("click", function (event) {
const button = event.target.closest("[data-insert-dialog]");
if (!button) {
return;
}
event.preventDefault();
let row;
try {
row = parseInsertDialogRow(button.dataset.row || "{}");
} catch (error) {
console.error(error);
return;
}
const reloadOnInsert = button.hasAttribute("data-insert-dialog-reload");
datasetteManager
.insertDialog(
button.dataset.database,
button.dataset.table,
row,
button.dataset.message || "",
{ flashMessage: reloadOnInsert },
)
.then((result) => {
if (
reloadOnInsert &&
result &&
result.ok &&
result.status === "inserted"
) {
window.location.reload();
}
})
.catch((error) => {
console.error(error);
});
});
}; };
/** /**

View file

@ -3,24 +3,37 @@ var rowDeleteDialogState = null;
var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
var rowEditDialogState = null; var rowEditDialogState = null;
function datasetteManagerPath(manager, path) {
if (manager && manager.path) {
return manager.path(path);
}
var baseUrl = window.datasetteBaseUrl || "/";
return baseUrl.replace(/\/?$/, "/") + String(path || "").replace(/^\/+/, "");
}
function ensureRowMutationStatus(manager) { function ensureRowMutationStatus(manager) {
var status = document.querySelector(".row-mutation-status"); var status = document.querySelector(".row-mutation-status");
if (status) { var tableWrapper = document.querySelector(manager.selectors.tableWrapper);
return status; var content = document.querySelector("section.content");
var fallbackParent = content && content.parentNode;
if (!status) {
status = document.createElement("p");
status.className = "row-mutation-status";
status.hidden = true;
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
status.setAttribute("tabindex", "-1");
} }
status = document.createElement("p");
status.className = "row-mutation-status";
status.hidden = true;
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
status.setAttribute("tabindex", "-1");
var tableWrapper = document.querySelector(manager.selectors.tableWrapper);
if (tableWrapper && tableWrapper.parentNode) { if (tableWrapper && tableWrapper.parentNode) {
tableWrapper.parentNode.insertBefore(status, tableWrapper); tableWrapper.parentNode.insertBefore(status, tableWrapper);
} else { } else if (content && fallbackParent) {
fallbackParent.insertBefore(status, content);
} else if (!status.parentNode) {
document.body.appendChild(status); document.body.appendChild(status);
} else {
document.body.insertBefore(status, document.body.firstChild);
} }
return status; return status;
} }
@ -124,9 +137,10 @@ function insertedRowStatusMessage(rowId, rowLabel) {
return message + "."; return message + ".";
} }
function tableBaseUrl() { function tableBaseUrl(state) {
var tableUrl = var tableUrl =
window._datasetteTableData && window._datasetteTableData.tableUrl; (state && state.currentTableUrl) ||
(window._datasetteTableData && window._datasetteTableData.tableUrl);
var url = new URL(tableUrl || location.href, location.href); var url = new URL(tableUrl || location.href, location.href);
url.hash = ""; url.hash = "";
url.search = ""; url.search = "";
@ -156,7 +170,10 @@ function rowElementForActionButton(button) {
); );
} }
function foreignKeyAutocompleteUrl(column) { function foreignKeyAutocompleteUrl(column, state) {
if (state && state.currentForeignKeys && state.currentForeignKeys[column]) {
return state.currentForeignKeys[column];
}
return tableForeignKeys()[column] || null; return tableForeignKeys()[column] || null;
} }
@ -304,13 +321,19 @@ async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) {
} }
} }
function tableInsertUrl() { function tableInsertUrl(data, options) {
var data = tableInsertData(); data = data || tableInsertData();
options = options || {};
var url;
if (data && data.path) { if (data && data.path) {
return new URL(data.path, location.href).toString(); url = new URL(data.path, location.href);
} else {
url = tableBaseUrl();
url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert";
}
if (options.flashMessage) {
url.searchParams.set("_message", "1");
} }
var url = tableBaseUrl();
url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert";
return url.toString(); return url.toString();
} }
@ -363,11 +386,11 @@ function rowFragmentUrl(row) {
return rowFragmentUrlById(rowId); return rowFragmentUrlById(rowId);
} }
function rowFragmentUrlById(rowId) { function rowFragmentUrlById(rowId, state) {
if (!rowId) { if (!rowId) {
return ""; return "";
} }
var url = tableBaseUrl(); var url = tableBaseUrl(state);
url.search = new URL(location.href).search; url.search = new URL(location.href).search;
url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment"; url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment";
url.searchParams.delete("_next"); url.searchParams.delete("_next");
@ -673,18 +696,20 @@ function defaultExpressionForContext(expression) {
} }
function columnFormControlContext(column, isPk, columnType, options) { function columnFormControlContext(column, isPk, columnType, options) {
options = options || {};
var pageData = tablePageData(); var pageData = tablePageData();
var defaultExpression = defaultExpressionForContext( var defaultExpression = defaultExpressionForContext(
options.defaultExpression, options.defaultExpression,
); );
return { return {
mode: options.mode || "edit", mode: options.mode || "edit",
database: pageData.database || null, database: options.database || pageData.database || null,
table: table:
options.table ||
pageData.table || pageData.table ||
(tableInsertData() && tableInsertData().tableName) || (tableInsertData() && tableInsertData().table_name) ||
null, null,
tableUrl: pageData.tableUrl || null, tableUrl: options.tableUrl || pageData.tableUrl || null,
column: column, column: column,
columnType: columnTypeForContext(columnType), columnType: columnTypeForContext(columnType),
sqliteType: options.sqliteType || null, sqliteType: options.sqliteType || null,
@ -1169,7 +1194,10 @@ function showRowEditDialogError(state, message) {
function updateRowEditDialogButtons(state) { function updateRowEditDialogButtons(state) {
state.saveButton.disabled = state.saveButton.disabled =
state.isLoading || state.isSaving || !state.hasLoaded; state.isLoading ||
state.isSaving ||
!state.hasLoaded ||
state.submitDelayActive;
state.cancelButton.disabled = state.isSaving; state.cancelButton.disabled = state.isSaving;
var saveLabel = state.mode === "insert" ? "Insert row" : "Save"; var saveLabel = state.mode === "insert" ? "Insert row" : "Save";
state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel; state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel;
@ -1190,6 +1218,30 @@ function setRowEditDialogSaving(state, isSaving) {
updateRowEditDialogButtons(state); updateRowEditDialogButtons(state);
} }
function clearRowEditDialogSubmitDelay(state) {
if (state.submitDelayTimer) {
clearTimeout(state.submitDelayTimer);
}
state.submitDelayTimer = null;
state.submitDelayActive = false;
}
function setRowEditDialogSubmitDelay(state, delayMs) {
clearRowEditDialogSubmitDelay(state);
delayMs = Number(delayMs) || 0;
if (delayMs <= 0) {
updateRowEditDialogButtons(state);
return;
}
state.submitDelayActive = true;
state.submitDelayTimer = setTimeout(function () {
state.submitDelayTimer = null;
state.submitDelayActive = false;
updateRowEditDialogButtons(state);
}, delayMs);
updateRowEditDialogButtons(state);
}
function valueFromRowEditControl(control) { function valueFromRowEditControl(control) {
var value = control.value; var value = control.value;
return valueFromRowEditText( return valueFromRowEditText(
@ -1471,6 +1523,40 @@ function rowPathFromRowData(row, primaryKeys) {
return bits.join(","); return bits.join(",");
} }
function rowUrlById(rowId, state) {
if (!rowId) {
return null;
}
var url = tableBaseUrl(state);
url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId;
return url.toString();
}
function insertDialogResult(state, row, rowId) {
var result = {
ok: true,
status: "inserted",
database: state.currentDatabase || null,
table: state.currentTable || null,
row: row || null,
};
if (rowId) {
result.row_id = tildeDecode(rowId);
result.row_path = rowId;
result.row_url = rowUrlById(rowId, state);
}
return result;
}
function resolveInsertDialog(state, result) {
if (!state.currentInsertDialogResolve) {
return;
}
var resolve = state.currentInsertDialogResolve;
state.currentInsertDialogResolve = null;
resolve(result);
}
function addInsertedRowToPage(rowElement) { function addInsertedRowToPage(rowElement) {
var importedRow = document.importNode(rowElement, true); var importedRow = document.importNode(rowElement, true);
var firstRow = document.querySelector("[data-row]"); var firstRow = document.querySelector("[data-row]");
@ -1491,7 +1577,12 @@ function addInsertedRowToPage(rowElement) {
} }
async function saveRowEditDialog(state) { async function saveRowEditDialog(state) {
if (state.isLoading || state.isSaving || !state.hasLoaded) { if (
state.isLoading ||
state.isSaving ||
!state.hasLoaded ||
state.submitDelayActive
) {
return; return;
} }
clearRowEditDialogError(state); clearRowEditDialogError(state);
@ -1537,15 +1628,30 @@ async function saveRowEditDialog(state) {
} }
if (state.mode === "insert") { if (state.mode === "insert") {
var insertData = tableInsertData() || {}; var insertData = state.currentInsertData || tableInsertData() || {};
var insertedRowData = var insertedRowData =
data && data.rows && data.rows.length ? data.rows[0] : null; data && data.rows && data.rows.length ? data.rows[0] : null;
var insertedRowId = rowPathFromRowData( var insertedRowId = rowPathFromRowData(
insertedRowData, insertedRowData,
insertData.primaryKeys || [], insertData.primary_keys || [],
); );
var result = insertDialogResult(state, insertedRowData, insertedRowId);
state.shouldRestoreFocus = false; state.shouldRestoreFocus = false;
if (!state.refreshAfterInsert) {
resolveInsertDialog(state, result);
state.dialog.close();
var status = showRowMutationStatus(
state.manager,
insertedRowId
? insertedRowStatusMessage(tildeDecode(insertedRowId), null)
: "Inserted row.",
false,
);
status.focus();
return;
}
if (!insertedRowId) { if (!insertedRowId) {
resolveInsertDialog(state, result);
state.dialog.close(); state.dialog.close();
var missingIdStatus = showRowMutationStatus( var missingIdStatus = showRowMutationStatus(
state.manager, state.manager,
@ -1557,11 +1663,12 @@ async function saveRowEditDialog(state) {
} }
state.currentRowId = insertedRowId; state.currentRowId = insertedRowId;
state.currentFragmentUrl = rowFragmentUrlById(insertedRowId); state.currentFragmentUrl = rowFragmentUrlById(insertedRowId, state);
var insertedRow = null; var insertedRow = null;
try { try {
insertedRow = await fetchUpdatedRowElement(state); insertedRow = await fetchUpdatedRowElement(state);
} catch (_error) { } catch (_error) {
resolveInsertDialog(state, result);
state.dialog.close(); state.dialog.close();
var refreshFailedStatus = showRowMutationStatus( var refreshFailedStatus = showRowMutationStatus(
state.manager, state.manager,
@ -1577,6 +1684,7 @@ async function saveRowEditDialog(state) {
rowTitleLabel(insertedRow), rowTitleLabel(insertedRow),
); );
var addedRow = addInsertedRowToPage(insertedRow); var addedRow = addInsertedRowToPage(insertedRow);
resolveInsertDialog(state, result);
state.dialog.close(); state.dialog.close();
showRowMutationStatus(state.manager, insertedStatusMessage, false); showRowMutationStatus(state.manager, insertedStatusMessage, false);
if (addedRow) { if (addedRow) {
@ -1586,6 +1694,7 @@ async function saveRowEditDialog(state) {
insertedFocusTarget.focus(); insertedFocusTarget.focus();
} }
} else { } else {
resolveInsertDialog(state, result);
state.dialog.close(); state.dialog.close();
var filteredStatus = showRowMutationStatus( var filteredStatus = showRowMutationStatus(
state.manager, state.manager,
@ -1662,7 +1771,7 @@ function renderRowEditFields(state, data) {
columnTypes[column], columnTypes[column],
index, index,
{ {
autocompleteUrl: foreignKeyAutocompleteUrl(column), autocompleteUrl: foreignKeyAutocompleteUrl(column, state),
dialog: state.dialog, dialog: state.dialog,
form: state.form, form: state.form,
manager: state.manager, manager: state.manager,
@ -1680,20 +1789,28 @@ function renderRowEditFields(state, data) {
} }
} }
function renderRowInsertFields(state, data) { function renderRowInsertFields(state, data, suggestedRow) {
var columns = data.columns || []; var columns = data.columns || [];
suggestedRow = suggestedRow || {};
destroyRowEditFields(state); destroyRowEditFields(state);
columns.forEach(function (column, index) { columns.forEach(function (column, index) {
var hasSuggestedValue = Object.prototype.hasOwnProperty.call(
suggestedRow,
column.name,
);
var value = hasSuggestedValue ? suggestedRow[column.name] : "";
var useSqliteDefault = column.default !== null && !hasSuggestedValue;
state.fields.appendChild( state.fields.appendChild(
createRowEditField( createRowEditField(
column.name, column.name,
"", value,
!!column.is_pk, !!column.is_pk,
column.column_type, column.column_type,
index, index,
{ {
autocompleteUrl: foreignKeyAutocompleteUrl(column.name), autocompleteUrl: foreignKeyAutocompleteUrl(column.name, state),
database: state.currentDatabase,
dialog: state.dialog, dialog: state.dialog,
form: state.form, form: state.form,
defaultExpression: column.default, defaultExpression: column.default,
@ -1702,8 +1819,12 @@ function renderRowInsertFields(state, data) {
notnull: column.notnull, notnull: column.notnull,
primaryKeyReadonly: false, primaryKeyReadonly: false,
sqliteType: column.sqlite_type, sqliteType: column.sqlite_type,
useSqliteDefault: column.default !== null, table: state.currentTable,
valueKind: column.value_kind, tableUrl: state.currentTableUrl,
useSqliteDefault: useSqliteDefault,
valueKind: hasSuggestedValue
? rowEditValueKind(value)
: column.value_kind,
}, },
), ),
); );
@ -1751,6 +1872,18 @@ function setRowDialogTitle(title, text, codeText, labelText) {
} }
} }
function setRowEditDialogSummary(state, message) {
if (message) {
state.summary.hidden = false;
state.summary.textContent = message;
state.dialog.setAttribute("aria-describedby", state.summary.id);
} else {
state.summary.hidden = true;
state.summary.textContent = "";
state.dialog.removeAttribute("aria-describedby");
}
}
function ensureRowEditDialog(manager) { function ensureRowEditDialog(manager) {
if (rowEditDialogState) { if (rowEditDialogState) {
return rowEditDialogState; return rowEditDialogState;
@ -1794,16 +1927,25 @@ function ensureRowEditDialog(manager) {
currentRow: null, currentRow: null,
currentRowId: null, currentRowId: null,
currentPkPath: null, currentPkPath: null,
currentDatabase: null,
currentTable: null,
currentTableUrl: null,
currentForeignKeys: null,
currentInsertData: null,
currentInsertDialogResolve: null,
currentInsertUrl: null, currentInsertUrl: null,
currentUpdateUrl: null, currentUpdateUrl: null,
currentFragmentUrl: null, currentFragmentUrl: null,
mode: "edit", mode: "edit",
refreshAfterInsert: false,
loadId: 0, loadId: 0,
manager: manager, manager: manager,
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
isClosePending: false, isClosePending: false,
hasLoaded: false, hasLoaded: false,
submitDelayActive: false,
submitDelayTimer: null,
shouldRestoreFocus: true, shouldRestoreFocus: true,
}; };
@ -1840,13 +1982,34 @@ function ensureRowEditDialog(manager) {
dialog.addEventListener("close", function () { dialog.addEventListener("close", function () {
var state = rowEditDialogState; var state = rowEditDialogState;
if (state.currentInsertDialogResolve) {
resolveInsertDialog(state, {
ok: false,
status: "cancelled",
database: state.currentDatabase || null,
table: state.currentTable || null,
});
}
state.loadId += 1; state.loadId += 1;
state.isClosePending = false; state.isClosePending = false;
clearRowEditDialogError(state); clearRowEditDialogError(state);
state.hasLoaded = false; state.hasLoaded = false;
clearRowEditDialogSubmitDelay(state);
destroyRowEditFields(state); destroyRowEditFields(state);
setRowEditDialogLoading(state, false); setRowEditDialogLoading(state, false);
setRowEditDialogSaving(state, false); setRowEditDialogSaving(state, false);
state.currentRow = null;
state.currentRowId = null;
state.currentPkPath = null;
state.currentDatabase = null;
state.currentTable = null;
state.currentTableUrl = null;
state.currentForeignKeys = null;
state.currentInsertData = null;
state.currentInsertUrl = null;
state.currentUpdateUrl = null;
state.currentFragmentUrl = null;
state.refreshAfterInsert = false;
if ( if (
state.shouldRestoreFocus && state.shouldRestoreFocus &&
state.currentButton && state.currentButton &&
@ -1875,9 +2038,16 @@ async function openRowEditDialog(button, manager) {
state.currentRow = row; state.currentRow = row;
state.currentRowId = row.getAttribute("data-row") || ""; state.currentRowId = row.getAttribute("data-row") || "";
state.currentPkPath = rowDisplayLabel(row); state.currentPkPath = rowDisplayLabel(row);
state.currentDatabase = tablePageData().database || null;
state.currentTable = tablePageData().table || null;
state.currentTableUrl = tablePageData().tableUrl || null;
state.currentForeignKeys = tableForeignKeys();
state.currentInsertData = null;
state.currentInsertDialogResolve = null;
state.currentInsertUrl = null; state.currentInsertUrl = null;
state.currentUpdateUrl = rowUpdateUrl(row); state.currentUpdateUrl = rowUpdateUrl(row);
state.currentFragmentUrl = rowFragmentUrl(row); state.currentFragmentUrl = rowFragmentUrl(row);
state.refreshAfterInsert = false;
if (state.currentUpdateUrl) { if (state.currentUpdateUrl) {
state.form.action = new URL( state.form.action = new URL(
state.currentUpdateUrl, state.currentUpdateUrl,
@ -1894,15 +2064,13 @@ async function openRowEditDialog(button, manager) {
clearRowEditDialogError(state); clearRowEditDialogError(state);
setRowEditDialogLoading(state, true); setRowEditDialogLoading(state, true);
destroyRowEditFields(state); destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle( setRowDialogTitle(
state.title, state.title,
"Edit row", "Edit row",
state.currentPkPath || "this row", state.currentPkPath || "this row",
rowTitleLabel(row), rowTitleLabel(row),
); );
state.summary.hidden = true; setRowEditDialogSummary(state, "");
state.summary.textContent = "";
if (!state.dialog.open) { if (!state.dialog.open) {
state.dialog.showModal(); state.dialog.showModal();
@ -1934,26 +2102,102 @@ async function openRowEditDialog(button, manager) {
} }
} }
function openRowInsertDialog(button, manager) { function validateSuggestedInsertRow(insertData, row) {
var insertData = tableInsertData(); row = row || {};
if (!insertData) { var columns = (insertData && insertData.columns) || [];
var columnNames = {};
columns.forEach(function (column) {
columnNames[column.name] = true;
});
var unknownColumns = Object.keys(row).filter(function (column) {
return !columnNames[column];
});
if (unknownColumns.length) {
throw new Error("Unknown column: " + unknownColumns.join(", "));
}
}
function insertDialogMetadataUrl(manager, database, table) {
return datasetteManagerPath(
manager,
"/" + tildeEncode(database) + "/" + tildeEncode(table) + "/-/insert",
);
}
async function fetchInsertDialogMetadata(manager, database, table) {
var response = await fetch(
insertDialogMetadataUrl(manager, database, table),
{
headers: {
Accept: "application/json",
},
},
);
var data = null;
try {
data = await response.json();
} catch (_error) {
data = null;
}
if (!response.ok || (data && data.ok === false)) {
throw rowMutationRequestError(response, data);
}
if (!data || !data.insert_row) {
throw new Error("Insert dialog metadata was not returned");
}
return data;
}
function hasForeignKeyAutocomplete(foreignKeys) {
return foreignKeys && Object.keys(foreignKeys).length > 0;
}
async function ensureAutocompleteLoaded(manager, foreignKeys) {
if (!hasForeignKeyAutocomplete(foreignKeys)) {
return; return;
} }
var state = ensureRowEditDialog(manager); if (window.customElements && customElements.get("datasette-autocomplete")) {
if (!state) {
return; return;
} }
if (manager && manager.loadAutocomplete) {
await manager.loadAutocomplete();
}
}
function openRowInsertDialogWithData(options) {
var insertData = options.insertData;
if (!insertData) {
return false;
}
var state = ensureRowEditDialog(options.manager);
if (!state) {
return false;
}
if (state.dialog.open) {
return false;
}
state.manager = manager; var pageData = tablePageData();
state.manager = options.manager;
state.mode = "insert"; state.mode = "insert";
state.currentButton = button; state.currentButton = options.button || null;
state.currentRow = null; state.currentRow = null;
state.currentRowId = null; state.currentRowId = null;
state.currentPkPath = null; state.currentPkPath = null;
state.currentInsertUrl = tableInsertUrl(); state.currentDatabase = options.database || pageData.database || null;
state.currentTable =
options.table || insertData.table_name || pageData.table || null;
state.currentTableUrl = options.tableUrl || pageData.tableUrl || null;
state.currentForeignKeys = options.foreignKeys || tableForeignKeys();
state.currentInsertData = insertData;
state.currentInsertDialogResolve = options.resolve || null;
state.currentInsertUrl = tableInsertUrl(insertData, {
flashMessage: options.flashMessage,
});
state.currentUpdateUrl = null; state.currentUpdateUrl = null;
state.currentFragmentUrl = null; state.currentFragmentUrl = null;
state.shouldRestoreFocus = true; state.refreshAfterInsert = !!options.refreshAfterInsert;
state.shouldRestoreFocus = !!options.button;
state.hasLoaded = false; state.hasLoaded = false;
state.loadId += 1; state.loadId += 1;
@ -1969,20 +2213,72 @@ function openRowInsertDialog(button, manager) {
clearRowEditDialogError(state); clearRowEditDialogError(state);
setRowEditDialogLoading(state, false); setRowEditDialogLoading(state, false);
destroyRowEditFields(state); destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby"); setRowEditDialogSubmitDelay(state, options.submitDelayMs);
setRowDialogTitle( setRowDialogTitle(
state.title, state.title,
insertData.tableName insertData.table_name ? "Insert row into" : "Insert row",
? "Insert row into " + insertData.tableName insertData.table_name,
: "Insert row",
); );
state.summary.hidden = true; setRowEditDialogSummary(state, options.message || "");
state.summary.textContent = "";
if (!state.dialog.open) { if (!state.dialog.open) {
state.dialog.showModal(); state.dialog.showModal();
} }
renderRowInsertFields(state, insertData); renderRowInsertFields(state, insertData, options.suggestedRow || {});
return true;
}
function openRowInsertDialog(button, manager) {
openRowInsertDialogWithData({
button: button,
manager: manager,
insertData: tableInsertData(),
database: tablePageData().database,
table: tablePageData().table,
tableUrl: tablePageData().tableUrl,
foreignKeys: tableForeignKeys(),
refreshAfterInsert: true,
});
}
async function insertDialog(manager, database, table, row, message, options) {
options = options || {};
if (typeof database !== "string" || !database) {
throw new Error("database must be a string");
}
if (typeof table !== "string" || !table) {
throw new Error("table must be a string");
}
if (!row) {
row = {};
}
if (typeof row !== "object" || Array.isArray(row)) {
throw new Error("row must be an object");
}
var metadata = await fetchInsertDialogMetadata(manager, database, table);
var insertData = metadata.insert_row;
var foreignKeys = metadata.foreign_keys || {};
validateSuggestedInsertRow(insertData, row);
await ensureAutocompleteLoaded(manager, foreignKeys);
return new Promise(function (resolve, reject) {
var opened = openRowInsertDialogWithData({
manager: manager,
insertData: insertData,
database: metadata.table && metadata.table.database,
table: metadata.table && metadata.table.name,
tableUrl: metadata.table && metadata.table.url,
foreignKeys: foreignKeys,
suggestedRow: row,
message: message || "",
resolve: resolve,
flashMessage: !!options.flashMessage,
refreshAfterInsert: false,
submitDelayMs: options.submitDelayMs,
});
if (!opened) {
reject(new Error("Could not open the insert dialog"));
}
});
} }
function initRowEditActions(manager) { function initRowEditActions(manager) {
@ -2013,11 +2309,27 @@ function initRowInsertActions(manager) {
}); });
} }
document.addEventListener("datasette_init", function (evt) { function installEditTools(manager) {
const { detail: manager } = evt; if (!manager || manager.__editToolsInstalled) {
return;
}
manager.__editToolsInstalled = true;
registerBuiltinColumnFieldPlugins(manager); registerBuiltinColumnFieldPlugins(manager);
initRowInsertActions(manager); initRowInsertActions(manager);
initRowEditActions(manager); initRowEditActions(manager);
initRowDeleteActions(manager); initRowDeleteActions(manager);
}
window.__DATASETTE_EDIT_TOOLS__ = {
install: installEditTools,
insertDialog: insertDialog,
};
if (window.__DATASETTE__) {
installEditTools(window.__DATASETTE__);
}
document.addEventListener("datasette_init", function (evt) {
installEditTools(evt.detail);
}); });

View file

@ -7,7 +7,12 @@
{% for url in extra_css_urls %} {% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}> <link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %} {% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script> <script>
window.datasetteVersion = {{ datasette_version|tojson }};
window.datasetteBaseUrl = {{ base_url|tojson }};
window.datasetteEditToolsUrl = {{ (urls.static('edit-tools.js') ~ '?hash=' ~ edit_tools_js_hash)|tojson }};
window.datasetteAutocompleteUrl = {{ urls.static('autocomplete.js')|tojson }};
</script>
<script src="{{ urls.static('datasette-manager.js') }}" defer></script> <script src="{{ urls.static('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %} {% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script> <script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>

View file

@ -42,12 +42,24 @@
{% if foreign_key_tables %} {% if foreign_key_tables %}
<h2>Links from other tables</h2> <h2>Links from other tables</h2>
<ul> <ul class="row-foreign-key-tables">
{% for other in foreign_key_tables %} {% for other in foreign_key_tables %}
<li> <li>
<a href="{{ other.link }}"> <a href="{{ other.link }}">
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a> {{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
from {{ other.other_column }} in {{ other.other_table }} from {{ other.other_column }} in {{ other.other_table }}
{% if other.can_insert %}
<button
type="button"
class="core row-foreign-key-insert"
data-insert-dialog
data-insert-dialog-reload
data-database="{{ database }}"
data-table="{{ other.other_table }}"
data-row="{{ other.insert_row|tojson|forceescape }}"
data-message="{{ other.insert_message }}"
>Insert</button>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -193,7 +193,7 @@ class RowView(DataView):
"private": private, "private": private,
"columns": reordered_columns, "columns": reordered_columns,
"foreign_key_tables": await self.foreign_key_tables( "foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values database, table, pk_values, actor=request.actor, include_insert=True
), ),
"database_color": db.color, "database_color": db.color,
"display_columns": display_columns, "display_columns": display_columns,
@ -266,7 +266,9 @@ class RowView(DataView):
), ),
) )
async def foreign_key_tables(self, database, table, pk_values): async def foreign_key_tables(
self, database, table, pk_values, actor=None, include_insert=False
):
if len(pk_values) != 1: if len(pk_values) != 1:
return [] return []
db = self.ds.databases[database] db = self.ds.databases[database]
@ -309,7 +311,24 @@ class RowView(DataView):
key, key,
",".join(pk_values), ",".join(pk_values),
) )
foreign_key_tables.append({**fk, **{"count": count, "link": link}}) item = {**fk, **{"count": count, "link": link}}
if include_insert:
can_insert = db.is_mutable and await self.ds.allowed(
action="insert-row",
resource=TableResource(database=database, table=fk["other_table"]),
actor=actor,
)
item["can_insert"] = can_insert
if can_insert:
item["insert_row"] = {fk["other_column"]: pk_values[0]}
item["insert_message"] = (
"Insert a row in {} with {} set to {}.".format(
fk["other_table"],
fk["other_column"],
pk_values[0],
)
)
foreign_key_tables.append(item)
return foreign_key_tables return foreign_key_tables

View file

@ -113,6 +113,35 @@ def row_label_from_label_column(row, label_column):
return str(value) return str(value)
ROW_FLASH_LABEL_MAX_LENGTH = 80
def _truncated_row_flash_label(label):
label = " ".join(str(label).split())
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
return label
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _inserted_row_flash_message(db, table_name, pks, row):
pk_label = path_from_row_pks(row, pks, pks == ["rowid"], False)
label_column = await db.label_column_for_table(table_name)
label = row_label_from_label_column(row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return "Inserted row {} ({})".format(pk_label, label)
return "Inserted row {}".format(pk_label)
def _inserted_rows_flash_message(table_name, num_rows):
return "Inserted {:,} row{} in {}".format(
num_rows,
"" if num_rows == 1 else "s",
table_name,
)
async def run_sequential(*args): async def run_sequential(*args):
# This used to be swappable for asyncio.gather() to run things in # This used to be swappable for asyncio.gather() to run things in
# parallel, but this lead to hard-to-debug locking issues with # parallel, but this lead to hard-to-debug locking issues with
@ -345,9 +374,9 @@ async def _table_insert_ui(
return { return {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)), "path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name, "table_name": table_name,
"columns": columns, "columns": columns,
"primaryKeys": pks, "primary_keys": pks,
} }
@ -655,6 +684,48 @@ class TableInsertView(BaseView):
def __init__(self, datasette): def __init__(self, datasette):
self.ds = datasette self.ds = datasette
async def get(self, request):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
if resolved.is_view:
return _error(["Cannot insert rows into a view"], 403)
if not db.is_mutable:
return _error(["Database is immutable"], 403)
if not await self.ds.allowed(
action="insert-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
pks = await db.primary_keys(table_name)
table_insert_ui = await _table_insert_ui(
self.ds, request, db, database_name, table_name, resolved.is_view, pks
)
return Response.json(
{
"ok": True,
"insert_row": table_insert_ui,
"table": {
"database": database_name,
"name": table_name,
"url": self.ds.urls.table(database_name, table_name),
},
"foreign_keys": await _foreign_key_autocomplete_urls(
self.ds, request, db, database_name, table_name
),
}
)
async def _validate_data(self, request, db, table_name, pks, upsert): async def _validate_data(self, request, db, table_name, pks, upsert):
errors = [] errors = []
@ -936,6 +1007,16 @@ class TableInsertView(BaseView):
) )
) )
if request.args.get("_message") and not upsert:
returned_rows = result.get("rows") or []
if len(returned_rows) == 1:
message = await _inserted_row_flash_message(
db, table_name, pks, returned_rows[0]
)
else:
message = _inserted_rows_flash_message(table_name, num_rows)
self.ds.add_message(request, message, self.ds.INFO)
return Response.json(result, status=200 if upsert else 201) return Response.json(result, status=200 if upsert else 201)

View file

@ -40,18 +40,88 @@ The ``datasetteManager`` object
``VERSION`` - string ``VERSION`` - string
The version of Datasette The version of Datasette
``path(path)`` - function
Returns a Datasette path, taking any configured :ref:`base URL prefix <setting_base_url>` into account.
``plugins`` - ``Map()`` ``plugins`` - ``Map()``
A Map of currently loaded plugin names to plugin implementations A Map of currently loaded plugin names to plugin implementations
``registerPlugin(name, implementation)`` ``registerPlugin(name, implementation)``
Call this to register a plugin, passing its name and implementation Call this to register a plugin, passing its name and implementation
``loadEditTools()`` - function
Loads Datasette's row insert/edit JavaScript on demand, returning a promise that resolves when those tools are ready. Calling this more than once is safe; subsequent calls reuse the already-loaded tools.
``loadAutocomplete()`` - function
Loads Datasette's foreign-key autocomplete custom element on demand, returning a promise that resolves when it is ready. Calling this more than once is safe; subsequent calls reuse the already-loaded element.
``insertDialog(database, table, row, message)`` - function
Opens Datasette's row insert dialog for ``database`` and ``table``, pre-filled with suggested values from ``row``. This returns a promise, described in :ref:`javascript_datasette_insert_dialog`.
``makeColumnField(context)`` ``makeColumnField(context)``
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs. Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
``selectors`` - object ``selectors`` - object
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>` An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
The same object is available as ``window.datasette`` for code that needs to call these APIs outside of the ``datasette_init`` event handler.
.. _javascript_datasette_insert_dialog:
insertDialog(database, table, row, message)
-------------------------------------------
``await datasette.insertDialog(database, table, row, message)`` opens Datasette's row insert dialog for the specified table.
The ``row`` argument must be an object. Its keys are column names and its values are the suggested values that should be pre-filled in the form. The row is not inserted immediately: the user can review and edit the values before clicking **Insert row**, or cancel the dialog.
``message`` is optional. If provided, it is shown at the top of the insert dialog to explain why the row is being suggested.
This example suggests a new ``repos`` row for a license:
.. code-block:: javascript
const result = await datasette.insertDialog(
"content",
"repos",
{ license: "apache-2.0" },
"Create a repo using the Apache 2.0 license."
);
if (result.ok) {
console.log("Inserted row:", result.row);
} else if (result.status === "cancelled") {
console.log("The user cancelled the insert.");
}
The promise resolves to an object. If the user inserts the row it resolves to:
.. code-block:: javascript
{
ok: true,
status: "inserted",
database: "content",
table: "repos",
row: { id: 1, name: "datasette", license: "apache-2.0" },
row_id: "1",
row_path: "1",
row_url: "/content/repos/1"
}
If the user cancels the dialog it resolves to:
.. code-block:: javascript
{
ok: false,
status: "cancelled",
database: "content",
table: "repos"
}
The method fetches the insert dialog metadata from ``GET /{database}/{table}/-/insert``. If the actor does not have ``insert-row`` permission for that table, or if ``row`` contains keys that are not available in the insert form, the promise rejects.
.. _javascript_plugin_objects: .. _javascript_plugin_objects:
JavaScript plugin objects JavaScript plugin objects

View file

@ -1530,7 +1530,7 @@ Here's how to serve ``data.db`` with CORS enabled::
The JSON write API The JSON write API
------------------ ------------------
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. Datasette provides a write API for JSON data. Write operations use ``POST`` and require an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
.. _ExecuteWriteView: .. _ExecuteWriteView:
@ -1643,6 +1643,46 @@ Inserting rows
This requires the :ref:`actions_insert_row` permission. This requires the :ref:`actions_insert_row` permission.
To return metadata describing the insert form for a table, make a ``GET`` request:
::
GET /<database>/<table>/-/insert
Authorization: Bearer dstok_<rest-of-token>
This returns a JSON object describing the table, the columns that can be inserted and any foreign key autocomplete URLs available to the actor:
.. code-block:: json
{
"ok": true,
"insert_row": {
"path": "/data/dogs/-/insert",
"table_name": "dogs",
"columns": [
{
"name": "name",
"sqlite_type": "TEXT",
"notnull": 1,
"default": null,
"has_default": false,
"is_pk": false,
"value_kind": "string",
"column_type": null
}
],
"primary_keys": ["id"]
},
"table": {
"database": "data",
"name": "dogs",
"url": "/data/dogs"
},
"foreign_keys": {}
}
Integer primary key columns that SQLite can populate automatically are omitted from ``insert_row.columns``. If the actor does not have ``insert-row`` permission this endpoint returns a ``403`` response.
A single row can be inserted using the ``"row"`` key: A single row can be inserted using the ``"row"`` key:
:: ::
@ -1674,6 +1714,8 @@ If successful, this will return a ``201`` status code and the newly inserted row
] ]
} }
Add ``?_message=1`` to the URL to set a Datasette flash message describing the inserted row. The JSON response body is unchanged, but the response will include a ``ds_messages`` cookie which Datasette will display on the next page the user visits.
To insert multiple rows at a time, use the same API method but send a list of dictionaries as the ``"rows"`` key: To insert multiple rows at a time, use the same API method but send a list of dictionaries as the ``"rows"`` key:
:: ::

View file

@ -700,7 +700,7 @@ def test_config_force_https_urls():
("/fixtures/-/query.json?sql=select+blah", 400), ("/fixtures/-/query.json?sql=select+blah", 400),
# Write APIs # Write APIs
("/fixtures/-/create", 405), ("/fixtures/-/create", 405),
("/fixtures/facetable/-/insert", 405), ("/fixtures/facetable/-/insert", 403),
("/fixtures/facetable/-/drop", 405), ("/fixtures/facetable/-/drop", 405),
], ],
) )

View file

@ -1595,7 +1595,6 @@ async def test_create_table_error_rows_twice_with_duplicates(ds_write):
( (
"/data/-/create", "/data/-/create",
"/data/docs/-/drop", "/data/docs/-/drop",
"/data/docs/-/insert",
), ),
) )
async def test_method_not_allowed(ds_write, path): async def test_method_not_allowed(ds_write, path):

View file

@ -90,20 +90,29 @@ def write_playwright_database(db_path):
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
conn.executescript(""" conn.executescript("""
create table licenses (
key text primary key,
name text
);
insert into licenses (key, name) values
('mit', 'MIT License'),
('apache-2.0', 'Apache License 2.0');
create table projects ( create table projects (
id integer primary key, id integer primary key,
title text not null, title text not null,
metadata text, metadata text,
logo text, logo text,
notes text, notes text,
license text references licenses(key),
score integer default 5 score integer default 5
); );
insert into projects (title, metadata, logo, notes, score) values insert into projects (title, metadata, logo, notes, license, score) values
( (
'Build Datasette', 'Build Datasette',
'{"ok": true}', '{"ok": true}',
'asset-original', 'asset-original',
'Initial notes', 'Initial notes',
'mit',
5 5
); );
""") """)
@ -347,6 +356,137 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
assert data["score"] == 5 assert data["score"] == 5
@pytest.mark.playwright
def test_insert_dialog_javascript_api_returns_inserted_row(page, datasette_server):
page.goto(datasette_server)
page.wait_for_function("window.datasette && window.datasette.insertDialog")
assert not page.locator('script[src*="edit-tools.js"]').count()
page.evaluate("""
() => {
window.insertDialogResult = window.datasette.insertDialog(
"data",
"projects",
{
title: "Suggested from JavaScript",
metadata: '{"suggested": true}',
notes: "Created through insertDialog()",
license: "apache-2.0",
score: 7
},
"Review this suggested project before inserting it."
);
}
""")
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
assert "Insert row into projects" in dialog.locator("#row-edit-title").inner_text()
assert (
dialog.locator("#row-edit-summary").inner_text()
== "Review this suggested project before inserting it."
)
assert dialog.locator('input[name="title"]').input_value() == (
"Suggested from JavaScript"
)
assert dialog.locator('textarea[name="metadata"]').input_value() == (
'{"suggested": true}'
)
assert dialog.locator('textarea[name="notes"]').input_value() == (
"Created through insertDialog()"
)
assert page.evaluate("() => !!customElements.get('datasette-autocomplete')")
assert (
dialog.locator('datasette-autocomplete input[name="license"]').input_value()
== "apache-2.0"
)
assert dialog.locator('input[name="score"]').input_value() == "7"
dialog.locator(".row-edit-save").click()
result = page.evaluate("() => window.insertDialogResult")
assert result["ok"] is True
assert result["status"] == "inserted"
assert result["database"] == "data"
assert result["table"] == "projects"
assert result["row_id"] == "2"
assert result["row_path"] == "2"
assert result["row_url"].endswith("/data/projects/2")
assert result["row"]["title"] == "Suggested from JavaScript"
assert result["row"]["metadata"] == '{"suggested": true}'
assert result["row"]["notes"] == "Created through insertDialog()"
assert result["row"]["license"] == "apache-2.0"
assert result["row"]["score"] == 7
page.locator(".row-mutation-status", has_text="Inserted row 2").wait_for()
assert page.evaluate("""
() => {
const status = document.querySelector(".row-mutation-status");
const footer = document.querySelector("footer.ft");
return !!(status.compareDocumentPosition(footer) &
Node.DOCUMENT_POSITION_FOLLOWING);
}
""")
data = project_row(datasette_server, 2)
assert data["title"] == "Suggested from JavaScript"
assert data["metadata"] == '{"suggested": true}'
assert data["notes"] == "Created through insertDialog()"
assert data["license"] == "apache-2.0"
assert data["score"] == 7
@pytest.mark.playwright
def test_insert_dialog_submit_delay_blocks_submit_until_elapsed(page, datasette_server):
page.goto(datasette_server)
page.wait_for_function("window.datasette && window.datasette.insertDialog")
page.evaluate("""
() => {
window.delayedInsertSettled = false;
window.delayedInsertResult = window.datasette.insertDialog(
"data",
"projects",
{
title: "Delayed JavaScript insert",
metadata: '{"delayed": true}',
notes: "The submit button starts disabled.",
license: "mit"
},
"Review this suggested project before inserting it.",
{submitDelayMs: 1000}
);
window.delayedInsertResult.then(() => {
window.delayedInsertSettled = true;
});
}
""")
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
save = dialog.locator(".row-edit-save")
save.wait_for()
assert save.is_disabled()
page.evaluate("""
() => document.querySelector("#row-edit-dialog form").requestSubmit()
""")
page.wait_for_timeout(100)
assert page.evaluate("() => window.delayedInsertSettled") is False
assert not project_rows(datasette_server, title="Delayed JavaScript insert")
page.wait_for_function("""
() => !document.querySelector("#row-edit-dialog .row-edit-save").disabled
""")
save.click()
result = page.evaluate("() => window.delayedInsertResult")
assert result["ok"] is True
assert result["status"] == "inserted"
assert result["row"]["title"] == "Delayed JavaScript insert"
data = project_row(datasette_server, 2)
assert data["title"] == "Delayed JavaScript insert"
assert data["license"] == "mit"
@pytest.mark.playwright @pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects") page.goto(f"{datasette_server}data/projects")

View file

@ -982,8 +982,8 @@ async def test_table_insert_action_button_and_data():
insert_data = table_data_from_soup(soup)["insertRow"] insert_data = table_data_from_soup(soup)["insertRow"]
assert insert_data["path"] == "/data/items/-/insert" assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items" assert insert_data["table_name"] == "items"
assert insert_data["primaryKeys"] == ["id"] assert insert_data["primary_keys"] == ["id"]
assert [column["name"] for column in insert_data["columns"]] == [ assert [column["name"] for column in insert_data["columns"]] == [
"name", "name",
"score", "score",
@ -1050,8 +1050,8 @@ async def test_table_insert_action_includes_compound_primary_keys():
insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[ insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[
"insertRow" "insertRow"
] ]
assert insert_data["tableName"] == "memberships" assert insert_data["table_name"] == "memberships"
assert insert_data["primaryKeys"] == ["account", "username"] assert insert_data["primary_keys"] == ["account", "username"]
assert [column["name"] for column in insert_data["columns"]] == [ assert [column["name"] for column in insert_data["columns"]] == [
"account", "account",
"username", "username",
@ -1066,6 +1066,127 @@ async def test_table_insert_action_includes_compound_primary_keys():
ds.close() ds.close()
@pytest.mark.asyncio
async def test_table_insert_metadata_api():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"insert-row": {"id": "root"},
},
"column_types": {"body": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_metadata_api"), name="data"
)
await db.execute_write_script("""
create table authors (
id integer primary key,
name text
);
create table items (
id integer primary key,
author_id integer references authors(id),
name text not null,
score integer default 5,
body text
);
""")
response = await ds.client.get("/data/items/-/insert", actor={"id": "root"})
assert response.status_code == 200
assert response.json() == {
"ok": True,
"insert_row": {
"path": "/data/items/-/insert",
"table_name": "items",
"columns": [
{
"name": "author_id",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "number",
"column_type": None,
},
{
"name": "name",
"sqlite_type": "TEXT",
"notnull": 1,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "string",
"column_type": None,
},
{
"name": "score",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": "5",
"has_default": True,
"is_pk": False,
"value_kind": "number",
"column_type": None,
},
{
"name": "body",
"sqlite_type": "TEXT",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": False,
"value_kind": "string",
"column_type": {"type": "textarea", "config": None},
},
],
"primary_keys": ["id"],
},
"table": {
"database": "data",
"name": "items",
"url": "/data/items",
},
"foreign_keys": {
"author_id": "/data/authors/-/autocomplete",
},
}
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_metadata_api_requires_insert_permission():
ds = Datasette([])
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_metadata_permission"),
name="data",
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data/items/-/insert")
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied"],
}
finally:
ds.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_table_data_includes_foreign_key_autocomplete_urls(): async def test_table_data_includes_foreign_key_autocomplete_urls():
ds = Datasette([]) ds = Datasette([])
@ -1121,6 +1242,76 @@ async def test_table_data_includes_foreign_key_autocomplete_urls():
ds.close() ds.close()
@pytest.mark.asyncio
async def test_row_foreign_key_tables_include_insert_dialog_button():
import json
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"repos": {
"permissions": {
"insert-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_fk_insert_dialog_button"),
name="data",
)
await db.execute_write_script("""
create table licenses (
key text primary key,
name text
);
create table repos (
id integer primary key,
name text,
license text references licenses(key)
);
insert into licenses (key, name)
values ('apache-2.0', 'Apache License 2.0');
insert into repos (name, license)
values ('datasette', 'apache-2.0');
""")
response = await ds.client.get(
"/data/licenses/apache-2~2E0", actor={"id": "root"}
)
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one('button[data-insert-dialog][data-table="repos"]')
assert button is not None
assert button.text.strip() == "Insert"
assert button["class"] == ["core", "row-foreign-key-insert"]
assert button.has_attr("data-insert-dialog-reload")
assert button["data-database"] == "data"
assert json.loads(button["data-row"]) == {"license": "apache-2.0"}
assert (
button["data-message"]
== "Insert a row in repos with license set to apache-2.0."
)
assert not any(
"edit-tools.js" in (script.get("src") or "")
for script in soup.find_all("script")
)
response = await ds.client.get("/data/licenses/apache-2~2E0")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert soup.select_one("button[data-insert-dialog]") is None
finally:
ds.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_table_fragment_endpoint(ds_client): async def test_table_fragment_endpoint(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1") response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
@ -1263,6 +1454,48 @@ async def test_row_delete_redirect_to_table_sets_message():
ds.close() ds.close()
@pytest.mark.asyncio
async def test_table_insert_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"insert-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_message"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.post(
"/data/items/-/insert?_message=1",
actor={"id": "root"},
json={"row": {"name": "One"}},
)
assert response.status_code == 201
assert response.json() == {
"ok": True,
"rows": [{"id": 1, "name": "One"}],
}
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Inserted row 1 (One)", ds.INFO]
]
finally:
ds.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_row_update_sets_message(): async def test_row_update_sets_message():
ds = Datasette( ds = Datasette(