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.
This commit is contained in:
Simon Willison 2026-06-19 23:40:15 -07:00
commit 76e6a3bc38
13 changed files with 791 additions and 52 deletions

View file

@ -1224,6 +1224,25 @@ button.table-insert-row svg {
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 {
--ink: #0f0f0f;
--paper: #eef6ff;

View file

@ -25,6 +25,29 @@ const DOM_SELECTORS = {
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
* 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?
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) => {
if (datasetteManager.plugins.has(name)) {
console.warn(`Warning -> plugin ${name} was redefined`);
@ -230,9 +309,7 @@ const datasetteManager = {
};
const initializeDatasette = () => {
// Hide the global behind __ prefix. Ideally they should be listening for the
// 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, {
@ -240,6 +317,43 @@ const initializeDatasette = () => {
});
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,6 +3,14 @@ var rowDeleteDialogState = null;
var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
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) {
var status = document.querySelector(".row-mutation-status");
if (status) {
@ -124,9 +132,10 @@ function insertedRowStatusMessage(rowId, rowLabel) {
return message + ".";
}
function tableBaseUrl() {
function tableBaseUrl(state) {
var tableUrl =
window._datasetteTableData && window._datasetteTableData.tableUrl;
(state && state.currentTableUrl) ||
(window._datasetteTableData && window._datasetteTableData.tableUrl);
var url = new URL(tableUrl || location.href, location.href);
url.hash = "";
url.search = "";
@ -156,7 +165,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;
}
@ -304,13 +316,19 @@ async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) {
}
}
function tableInsertUrl() {
var data = tableInsertData();
function tableInsertUrl(data, options) {
data = data || tableInsertData();
options = options || {};
var url;
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();
}
@ -363,11 +381,11 @@ function rowFragmentUrl(row) {
return rowFragmentUrlById(rowId);
}
function rowFragmentUrlById(rowId) {
function rowFragmentUrlById(rowId, state) {
if (!rowId) {
return "";
}
var url = tableBaseUrl();
var url = tableBaseUrl(state);
url.search = new URL(location.href).search;
url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment";
url.searchParams.delete("_next");
@ -673,18 +691,20 @@ function defaultExpressionForContext(expression) {
}
function columnFormControlContext(column, isPk, columnType, options) {
options = options || {};
var pageData = tablePageData();
var defaultExpression = defaultExpressionForContext(
options.defaultExpression,
);
return {
mode: options.mode || "edit",
database: pageData.database || null,
database: options.database || pageData.database || null,
table:
options.table ||
pageData.table ||
(tableInsertData() && tableInsertData().table_name) ||
null,
tableUrl: pageData.tableUrl || null,
tableUrl: options.tableUrl || pageData.tableUrl || null,
column: column,
columnType: columnTypeForContext(columnType),
sqliteType: options.sqliteType || null,
@ -1471,6 +1491,40 @@ function rowPathFromRowData(row, primaryKeys) {
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) {
var importedRow = document.importNode(rowElement, true);
var firstRow = document.querySelector("[data-row]");
@ -1537,15 +1591,30 @@ async function saveRowEditDialog(state) {
}
if (state.mode === "insert") {
var insertData = tableInsertData() || {};
var insertData = state.currentInsertData || tableInsertData() || {};
var insertedRowData =
data && data.rows && data.rows.length ? data.rows[0] : null;
var insertedRowId = rowPathFromRowData(
insertedRowData,
insertData.primary_keys || [],
);
var result = insertDialogResult(state, insertedRowData, insertedRowId);
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) {
resolveInsertDialog(state, result);
state.dialog.close();
var missingIdStatus = showRowMutationStatus(
state.manager,
@ -1557,11 +1626,12 @@ async function saveRowEditDialog(state) {
}
state.currentRowId = insertedRowId;
state.currentFragmentUrl = rowFragmentUrlById(insertedRowId);
state.currentFragmentUrl = rowFragmentUrlById(insertedRowId, state);
var insertedRow = null;
try {
insertedRow = await fetchUpdatedRowElement(state);
} catch (_error) {
resolveInsertDialog(state, result);
state.dialog.close();
var refreshFailedStatus = showRowMutationStatus(
state.manager,
@ -1577,6 +1647,7 @@ async function saveRowEditDialog(state) {
rowTitleLabel(insertedRow),
);
var addedRow = addInsertedRowToPage(insertedRow);
resolveInsertDialog(state, result);
state.dialog.close();
showRowMutationStatus(state.manager, insertedStatusMessage, false);
if (addedRow) {
@ -1586,6 +1657,7 @@ async function saveRowEditDialog(state) {
insertedFocusTarget.focus();
}
} else {
resolveInsertDialog(state, result);
state.dialog.close();
var filteredStatus = showRowMutationStatus(
state.manager,
@ -1662,7 +1734,7 @@ function renderRowEditFields(state, data) {
columnTypes[column],
index,
{
autocompleteUrl: foreignKeyAutocompleteUrl(column),
autocompleteUrl: foreignKeyAutocompleteUrl(column, state),
dialog: state.dialog,
form: state.form,
manager: state.manager,
@ -1680,20 +1752,28 @@ function renderRowEditFields(state, data) {
}
}
function renderRowInsertFields(state, data) {
function renderRowInsertFields(state, data, suggestedRow) {
var columns = data.columns || [];
suggestedRow = suggestedRow || {};
destroyRowEditFields(state);
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(
createRowEditField(
column.name,
"",
value,
!!column.is_pk,
column.column_type,
index,
{
autocompleteUrl: foreignKeyAutocompleteUrl(column.name),
autocompleteUrl: foreignKeyAutocompleteUrl(column.name, state),
database: state.currentDatabase,
dialog: state.dialog,
form: state.form,
defaultExpression: column.default,
@ -1702,8 +1782,12 @@ function renderRowInsertFields(state, data) {
notnull: column.notnull,
primaryKeyReadonly: false,
sqliteType: column.sqlite_type,
useSqliteDefault: column.default !== null,
valueKind: column.value_kind,
table: state.currentTable,
tableUrl: state.currentTableUrl,
useSqliteDefault: useSqliteDefault,
valueKind: hasSuggestedValue
? rowEditValueKind(value)
: column.value_kind,
},
),
);
@ -1751,6 +1835,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) {
if (rowEditDialogState) {
return rowEditDialogState;
@ -1794,10 +1890,17 @@ function ensureRowEditDialog(manager) {
currentRow: null,
currentRowId: null,
currentPkPath: null,
currentDatabase: null,
currentTable: null,
currentTableUrl: null,
currentForeignKeys: null,
currentInsertData: null,
currentInsertDialogResolve: null,
currentInsertUrl: null,
currentUpdateUrl: null,
currentFragmentUrl: null,
mode: "edit",
refreshAfterInsert: false,
loadId: 0,
manager: manager,
isLoading: false,
@ -1840,6 +1943,14 @@ function ensureRowEditDialog(manager) {
dialog.addEventListener("close", function () {
var state = rowEditDialogState;
if (state.currentInsertDialogResolve) {
resolveInsertDialog(state, {
ok: false,
status: "cancelled",
database: state.currentDatabase || null,
table: state.currentTable || null,
});
}
state.loadId += 1;
state.isClosePending = false;
clearRowEditDialogError(state);
@ -1847,6 +1958,18 @@ function ensureRowEditDialog(manager) {
destroyRowEditFields(state);
setRowEditDialogLoading(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 (
state.shouldRestoreFocus &&
state.currentButton &&
@ -1875,9 +1998,16 @@ async function openRowEditDialog(button, manager) {
state.currentRow = row;
state.currentRowId = row.getAttribute("data-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.currentUpdateUrl = rowUpdateUrl(row);
state.currentFragmentUrl = rowFragmentUrl(row);
state.refreshAfterInsert = false;
if (state.currentUpdateUrl) {
state.form.action = new URL(
state.currentUpdateUrl,
@ -1894,15 +2024,13 @@ async function openRowEditDialog(button, manager) {
clearRowEditDialogError(state);
setRowEditDialogLoading(state, true);
destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle(
state.title,
"Edit row",
state.currentPkPath || "this row",
rowTitleLabel(row),
);
state.summary.hidden = true;
state.summary.textContent = "";
setRowEditDialogSummary(state, "");
if (!state.dialog.open) {
state.dialog.showModal();
@ -1934,26 +2062,102 @@ async function openRowEditDialog(button, manager) {
}
}
function openRowInsertDialog(button, manager) {
var insertData = tableInsertData();
if (!insertData) {
function validateSuggestedInsertRow(insertData, row) {
row = row || {};
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;
}
var state = ensureRowEditDialog(manager);
if (!state) {
if (window.customElements && customElements.get("datasette-autocomplete")) {
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.currentButton = button;
state.currentButton = options.button || null;
state.currentRow = null;
state.currentRowId = 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.currentFragmentUrl = null;
state.shouldRestoreFocus = true;
state.refreshAfterInsert = !!options.refreshAfterInsert;
state.shouldRestoreFocus = !!options.button;
state.hasLoaded = false;
state.loadId += 1;
@ -1969,20 +2173,70 @@ function openRowInsertDialog(button, manager) {
clearRowEditDialogError(state);
setRowEditDialogLoading(state, false);
destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
setRowDialogTitle(
state.title,
insertData.table_name
? "Insert row into " + insertData.table_name
: "Insert row",
insertData.table_name ? "Insert row into" : "Insert row",
insertData.table_name,
);
state.summary.hidden = true;
state.summary.textContent = "";
setRowEditDialogSummary(state, options.message || "");
if (!state.dialog.open) {
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,
});
if (!opened) {
reject(new Error("Could not open the insert dialog"));
}
});
}
function initRowEditActions(manager) {
@ -2013,11 +2267,27 @@ function initRowInsertActions(manager) {
});
}
document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt;
function installEditTools(manager) {
if (!manager || manager.__editToolsInstalled) {
return;
}
manager.__editToolsInstalled = true;
registerBuiltinColumnFieldPlugins(manager);
initRowInsertActions(manager);
initRowEditActions(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 %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% 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>
{% 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>

View file

@ -42,12 +42,24 @@
{% if foreign_key_tables %}
<h2>Links from other tables</h2>
<ul>
<ul class="row-foreign-key-tables">
{% for other in foreign_key_tables %}
<li>
<a href="{{ other.link }}">
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
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>
{% endfor %}
</ul>

View file

@ -193,7 +193,7 @@ class RowView(DataView):
"private": private,
"columns": reordered_columns,
"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,
"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:
return []
db = self.ds.databases[database]
@ -309,7 +311,24 @@ class RowView(DataView):
key,
",".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

View file

@ -113,6 +113,35 @@ def row_label_from_label_column(row, label_column):
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):
# This used to be swappable for asyncio.gather() to run things in
# parallel, but this lead to hard-to-debug locking issues with
@ -978,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)

View file

@ -40,18 +40,88 @@ The ``datasetteManager`` object
``VERSION`` - string
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()``
A Map of currently loaded plugin names to plugin implementations
``registerPlugin(name, 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)``
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
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

View file

@ -1714,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:
::

View file

@ -700,7 +700,7 @@ def test_config_force_https_urls():
("/fixtures/-/query.json?sql=select+blah", 400),
# Write APIs
("/fixtures/-/create", 405),
("/fixtures/facetable/-/insert", 405),
("/fixtures/facetable/-/insert", 403),
("/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/docs/-/drop",
"/data/docs/-/insert",
),
)
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)
try:
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 (
id integer primary key,
title text not null,
metadata text,
logo text,
notes text,
license text references licenses(key),
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',
'{"ok": true}',
'asset-original',
'Initial notes',
'mit',
5
);
""")
@ -347,6 +356,75 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
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
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_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")

View file

@ -1242,6 +1242,76 @@ async def test_table_data_includes_foreign_key_autocomplete_urls():
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
async def test_table_fragment_endpoint(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
@ -1384,6 +1454,48 @@ async def test_row_delete_redirect_to_table_sets_message():
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
async def test_row_update_sets_message():
ds = Datasette(