mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 05:26:59 +02:00
Display of edit modal (no save yet)
This commit is contained in:
parent
20824bd707
commit
ad3456dc4a
5 changed files with 530 additions and 2 deletions
|
|
@ -1333,6 +1333,199 @@ dialog.row-delete-dialog::backdrop {
|
|||
cursor: wait;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
max-height: min(780px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-error {
|
||||
margin: 0;
|
||||
padding: 12px 24px 0;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-edit-id {
|
||||
display: inline;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-edit-fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 8px;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-control-wrap {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea.row-edit-input {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-input[readonly] {
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-field-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-link-with-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1443,6 +1636,35 @@ dialog.row-delete-dialog::backdrop {
|
|||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-error,
|
||||
.row-edit-fields {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-inline-action {
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
|
|
@ -1507,6 +1729,10 @@ dialog.row-delete-dialog::backdrop {
|
|||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 100px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
|||
var setColumnTypeDialogState = null;
|
||||
var ROW_DELETE_DIALOG_ID = "row-delete-dialog";
|
||||
var rowDeleteDialogState = null;
|
||||
var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
|
||||
var rowEditDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
|
|
@ -627,6 +629,290 @@ function initRowDeleteActions(manager) {
|
|||
});
|
||||
}
|
||||
|
||||
function rowJsonUrl(row) {
|
||||
var url = new URL(row.dataset.rowUrl, location.href);
|
||||
url.pathname = url.pathname + ".json";
|
||||
url.searchParams.set("_extra", "columns,column_types");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function valueToEditText(value) {
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function shouldUseTextarea(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return true;
|
||||
}
|
||||
var text = valueToEditText(value);
|
||||
return text.length > 80 || text.indexOf("\n") !== -1;
|
||||
}
|
||||
|
||||
function createRowEditField(column, value, isPk, columnType, index) {
|
||||
var field = document.createElement("div");
|
||||
field.className = "row-edit-field";
|
||||
|
||||
var fieldId = "row-edit-field-" + index;
|
||||
var metaId = "row-edit-field-meta-" + index;
|
||||
var label = document.createElement("label");
|
||||
label.className = "row-edit-label";
|
||||
label.setAttribute("for", fieldId);
|
||||
label.textContent = column;
|
||||
|
||||
var controlWrap = document.createElement("div");
|
||||
controlWrap.className = "row-edit-control-wrap";
|
||||
|
||||
var control = shouldUseTextarea(value)
|
||||
? document.createElement("textarea")
|
||||
: document.createElement("input");
|
||||
control.className = "row-edit-input";
|
||||
control.id = fieldId;
|
||||
control.name = column;
|
||||
control.value = valueToEditText(value);
|
||||
control.setAttribute("aria-describedby", metaId);
|
||||
control.dataset.originalValue = valueToEditText(value);
|
||||
|
||||
if (control.nodeName === "TEXTAREA") {
|
||||
control.rows = Math.min(8, Math.max(3, control.value.split("\n").length));
|
||||
} else {
|
||||
control.type = "text";
|
||||
}
|
||||
|
||||
if (isPk) {
|
||||
control.readOnly = true;
|
||||
}
|
||||
|
||||
var meta = document.createElement("span");
|
||||
meta.id = metaId;
|
||||
meta.className = "row-edit-field-meta";
|
||||
var metaParts = [];
|
||||
if (isPk) {
|
||||
metaParts.push("Primary key");
|
||||
}
|
||||
if (value === null) {
|
||||
metaParts.push("Current value: NULL");
|
||||
control.placeholder = "NULL";
|
||||
}
|
||||
if (columnType && columnType.type) {
|
||||
metaParts.push("Custom type: " + columnType.type);
|
||||
}
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
|
||||
controlWrap.appendChild(control);
|
||||
if (meta.textContent) {
|
||||
controlWrap.appendChild(meta);
|
||||
}
|
||||
field.appendChild(label);
|
||||
field.appendChild(controlWrap);
|
||||
return field;
|
||||
}
|
||||
|
||||
function clearRowEditDialogError(state) {
|
||||
state.error.hidden = true;
|
||||
state.error.textContent = "";
|
||||
}
|
||||
|
||||
function showRowEditDialogError(state, message) {
|
||||
state.error.hidden = false;
|
||||
state.error.textContent = message;
|
||||
}
|
||||
|
||||
function setRowEditDialogLoading(state, isLoading) {
|
||||
state.isLoading = isLoading;
|
||||
state.loading.hidden = !isLoading;
|
||||
}
|
||||
|
||||
function renderRowEditFields(state, data) {
|
||||
var row = data.rows && data.rows.length ? data.rows[0] : null;
|
||||
var columns = data.columns || (row ? Object.keys(row) : []);
|
||||
var primaryKeys = data.primary_keys || [];
|
||||
var columnTypes = data.column_types || {};
|
||||
|
||||
state.fields.innerHTML = "";
|
||||
columns.forEach(function (column, index) {
|
||||
state.fields.appendChild(
|
||||
createRowEditField(
|
||||
column,
|
||||
row ? row[column] : null,
|
||||
primaryKeys.indexOf(column) !== -1,
|
||||
columnTypes[column],
|
||||
index,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
var firstEditable = state.fields.querySelector(".row-edit-input:not([readonly])");
|
||||
var firstField = state.fields.querySelector(".row-edit-input");
|
||||
(firstEditable || firstField || state.cancelButton).focus();
|
||||
}
|
||||
|
||||
function ensureRowEditDialog(manager) {
|
||||
if (rowEditDialogState) {
|
||||
return rowEditDialogState;
|
||||
}
|
||||
if (!window.HTMLDialogElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var dialog = document.createElement("dialog");
|
||||
dialog.id = ROW_EDIT_DIALOG_ID;
|
||||
dialog.className = "row-edit-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
||||
dialog.setAttribute("aria-describedby", "row-edit-summary");
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="row-edit-title">Edit row</span>
|
||||
</div>
|
||||
<form class="row-edit-form">
|
||||
<p class="row-edit-summary" id="row-edit-summary">Editing row <span class="row-edit-id"></span></p>
|
||||
<p class="row-edit-loading">Loading row...</p>
|
||||
<p class="row-edit-error" role="alert" hidden></p>
|
||||
<div class="row-edit-fields"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary row-edit-save" disabled>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
rowEditDialogState = {
|
||||
dialog: dialog,
|
||||
form: dialog.querySelector(".row-edit-form"),
|
||||
rowId: dialog.querySelector(".row-edit-id"),
|
||||
loading: dialog.querySelector(".row-edit-loading"),
|
||||
error: dialog.querySelector(".row-edit-error"),
|
||||
fields: dialog.querySelector(".row-edit-fields"),
|
||||
cancelButton: dialog.querySelector(".row-edit-cancel"),
|
||||
saveButton: dialog.querySelector(".row-edit-save"),
|
||||
currentButton: null,
|
||||
currentRow: null,
|
||||
currentPkPath: null,
|
||||
loadId: 0,
|
||||
manager: manager,
|
||||
isLoading: false,
|
||||
shouldRestoreFocus: true,
|
||||
};
|
||||
|
||||
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
});
|
||||
|
||||
rowEditDialogState.cancelButton.addEventListener("click", function () {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", function (ev) {
|
||||
if (ev.target === dialog) {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function () {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", function () {
|
||||
var state = rowEditDialogState;
|
||||
state.loadId += 1;
|
||||
clearRowEditDialogError(state);
|
||||
setRowEditDialogLoading(state, false);
|
||||
if (
|
||||
state.shouldRestoreFocus &&
|
||||
state.currentButton &&
|
||||
document.contains(state.currentButton)
|
||||
) {
|
||||
state.currentButton.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return rowEditDialogState;
|
||||
}
|
||||
|
||||
async function openRowEditDialog(button, manager) {
|
||||
var row = button.closest("tr[data-row-url]");
|
||||
if (!row || !row.dataset.rowUrl) {
|
||||
return;
|
||||
}
|
||||
var state = ensureRowEditDialog(manager);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.manager = manager;
|
||||
state.currentButton = button;
|
||||
state.currentRow = row;
|
||||
state.currentPkPath = row.dataset.rowPkPath || "";
|
||||
state.shouldRestoreFocus = true;
|
||||
state.loadId += 1;
|
||||
var loadId = state.loadId;
|
||||
|
||||
clearRowEditDialogError(state);
|
||||
setRowEditDialogLoading(state, true);
|
||||
state.fields.innerHTML = "";
|
||||
state.rowId.textContent = state.currentPkPath || "this row";
|
||||
|
||||
if (!state.dialog.open) {
|
||||
state.dialog.showModal();
|
||||
}
|
||||
state.cancelButton.focus();
|
||||
|
||||
try {
|
||||
var response = await fetch(rowJsonUrl(row), {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
var data = await response.json();
|
||||
if (loadId !== state.loadId) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw rowDeleteRequestError(response, data);
|
||||
}
|
||||
setRowEditDialogLoading(state, false);
|
||||
renderRowEditFields(state, data);
|
||||
} catch (error) {
|
||||
if (loadId !== state.loadId) {
|
||||
return;
|
||||
}
|
||||
setRowEditDialogLoading(state, false);
|
||||
showRowEditDialogError(state, error.message || "Could not load row");
|
||||
state.cancelButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function initRowEditActions(manager) {
|
||||
if (!window.fetch || !window.HTMLDialogElement) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest('button[data-row-action="edit"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
openRowEditDialog(button, manager);
|
||||
});
|
||||
}
|
||||
|
||||
function canChooseColumns() {
|
||||
return !!(
|
||||
document.querySelector("column-chooser") && window._columnChooserData
|
||||
|
|
@ -1022,6 +1308,7 @@ document.addEventListener("datasette_init", function (evt) {
|
|||
|
||||
// Main table
|
||||
initDatasetteTable(manager);
|
||||
initRowEditActions(manager);
|
||||
initRowDeleteActions(manager);
|
||||
|
||||
// Other UI functions with interactive JS needs
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr{% if row.pk_path is not none %} data-row-pk-path="{{ row.pk_path }}" data-row-path="{{ row.row_path }}" data-row-url="{{ row.row_url }}" data-row-delete-url="{{ row.delete_url }}"{% endif %}>
|
||||
<tr{% if row.pk_path is not none %} data-row-pk-path="{{ row.pk_path }}" data-row-path="{{ row.row_path }}" data-row-url="{{ row.row_url }}" data-row-delete-url="{{ row.delete_url }}" data-row-update-url="{{ row.update_url }}"{% endif %}>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -66,12 +66,14 @@ class Row:
|
|||
row_path=None,
|
||||
row_url=None,
|
||||
delete_url=None,
|
||||
update_url=None,
|
||||
):
|
||||
self.cells = cells
|
||||
self.pk_path = pk_path
|
||||
self.row_path = row_path
|
||||
self.row_url = row_url
|
||||
self.delete_url = delete_url
|
||||
self.update_url = update_url
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.cells)
|
||||
|
|
@ -258,6 +260,7 @@ async def display_columns_and_rows(
|
|||
row_path=row_path,
|
||||
)
|
||||
delete_url = "{row_url}/-/delete".format(row_url=row_url)
|
||||
update_url = "{row_url}/-/update".format(row_url=row_url)
|
||||
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||
table_path=table_path,
|
||||
flat_pks=str(markupsafe.escape(pk_path)),
|
||||
|
|
@ -288,7 +291,8 @@ async def display_columns_and_rows(
|
|||
if row_action_permissions.get("update-row"):
|
||||
row_actions.append(
|
||||
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
||||
'aria-label="Edit row {row_label}" title="Edit row">'
|
||||
'aria-label="Edit row {row_label}" title="Edit row" '
|
||||
'data-row-action="edit">'
|
||||
"{edit_icon}</button>".format(
|
||||
edit_icon=edit_icon,
|
||||
row_label=markupsafe.escape(pk_path),
|
||||
|
|
@ -430,6 +434,7 @@ async def display_columns_and_rows(
|
|||
row_path=row_path,
|
||||
row_url=row_url,
|
||||
delete_url=delete_url,
|
||||
update_url=update_url,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -839,6 +839,7 @@ async def test_row_delete_action_data_attributes():
|
|||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
|
|
@ -863,6 +864,15 @@ async def test_row_delete_action_data_attributes():
|
|||
assert row["data-row-path"] == "1"
|
||||
assert row["data-row-url"] == "/data/items/1"
|
||||
assert row["data-row-delete-url"] == "/data/items/1/-/delete"
|
||||
assert row["data-row-update-url"] == "/data/items/1/-/update"
|
||||
|
||||
edit_button = row.select_one(
|
||||
'button.row-inline-action-edit[data-row-action="edit"]'
|
||||
)
|
||||
assert edit_button is not None
|
||||
assert edit_button["aria-label"] == "Edit row 1"
|
||||
assert edit_button["title"] == "Edit row"
|
||||
assert edit_button.find("svg") is not None
|
||||
|
||||
button = row.select_one(
|
||||
'button.row-inline-action-delete[data-row-action="delete"]'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue