mirror of
https://github.com/simonw/datasette.git
synced 2026-07-14 03:24:42 +02:00
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:
parent
f3fa6ecf61
commit
76e6a3bc38
13 changed files with 791 additions and 52 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
::
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue