mirror of
https://github.com/simonw/datasette.git
synced 2026-06-17 14:27:47 +02:00
Merge branch 'main' into playwright-tests
This commit is contained in:
commit
2a887e5853
9 changed files with 140 additions and 20 deletions
|
|
@ -1396,6 +1396,38 @@ function closeRowEditDialogIfConfirmed(state) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function scheduleCloseRowEditDialogIfConfirmed(state) {
|
||||
// Fix for an issue in Safari where hitting Esc would show
|
||||
// the confirm() prompt asking if state should be discarded
|
||||
// but the Esc key press would then cancel that dialog too.
|
||||
// Wait for keyup, then move the confirm() to a fresh timer tick.
|
||||
if (!state || state.isSaving || state.isClosePending) {
|
||||
return false;
|
||||
}
|
||||
if (!rowEditDialogHasChanges(state)) {
|
||||
state.shouldRestoreFocus = true;
|
||||
state.dialog.close();
|
||||
return true;
|
||||
}
|
||||
state.isClosePending = true;
|
||||
var closeAfterKeyup = function () {
|
||||
if (!state.isClosePending) {
|
||||
return;
|
||||
}
|
||||
state.isClosePending = false;
|
||||
closeRowEditDialogIfConfirmed(state);
|
||||
};
|
||||
var onKeyup = function (ev) {
|
||||
if (ev.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener("keyup", onKeyup, true);
|
||||
setTimeout(closeAfterKeyup, 0);
|
||||
};
|
||||
document.addEventListener("keyup", onKeyup, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function findDataRowElement(root, rowId) {
|
||||
var elements = root.querySelectorAll("[data-row]");
|
||||
for (var i = 0; i < elements.length; i += 1) {
|
||||
|
|
@ -1770,6 +1802,7 @@ function ensureRowEditDialog(manager) {
|
|||
manager: manager,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
isClosePending: false,
|
||||
hasLoaded: false,
|
||||
shouldRestoreFocus: true,
|
||||
};
|
||||
|
|
@ -1797,23 +1830,18 @@ function ensureRowEditDialog(manager) {
|
|||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
closeRowEditDialogIfConfirmed(rowEditDialogState);
|
||||
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function (ev) {
|
||||
if (
|
||||
rowEditDialogState.isSaving ||
|
||||
!confirmDiscardRowEditChanges(rowEditDialogState)
|
||||
) {
|
||||
ev.preventDefault();
|
||||
} else {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
}
|
||||
ev.preventDefault();
|
||||
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", function () {
|
||||
var state = rowEditDialogState;
|
||||
state.loadId += 1;
|
||||
state.isClosePending = false;
|
||||
clearRowEditDialogError(state);
|
||||
state.hasLoaded = false;
|
||||
destroyRowEditFields(state);
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@ class Request:
|
|||
body = await self.post_body()
|
||||
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
||||
|
||||
async def json(self):
|
||||
body = await self.post_body()
|
||||
return json.loads(body)
|
||||
|
||||
async def form(
|
||||
self,
|
||||
files: bool = False,
|
||||
|
|
|
|||
|
|
@ -1093,9 +1093,8 @@ class TableCreateView(BaseView):
|
|||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
body = await request.post_body()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
|
||||
|
|
|
|||
|
|
@ -418,9 +418,8 @@ class RowUpdateView(BaseView):
|
|||
if not ok:
|
||||
return resolved
|
||||
|
||||
body = await request.post_body()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
|
||||
|
|
|
|||
|
|
@ -673,9 +673,8 @@ class TableInsertView(BaseView):
|
|||
if not request.headers.get("content-type").startswith("application/json"):
|
||||
# TODO: handle form-encoded data
|
||||
return _errors(["Invalid content-type, must be application/json"])
|
||||
body = await request.post_body()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _errors(["Invalid JSON: {}".format(e)])
|
||||
if not isinstance(data, dict):
|
||||
|
|
@ -974,7 +973,7 @@ class TableSetColumnTypeView(BaseView):
|
|||
return _error(["Invalid content-type, must be application/json"], 400)
|
||||
|
||||
try:
|
||||
data = json.loads(await request.post_body())
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)], 400)
|
||||
|
||||
|
|
@ -1091,7 +1090,7 @@ class TableDropView(BaseView):
|
|||
return _error(["Database is immutable"], 403)
|
||||
confirm = False
|
||||
try:
|
||||
data = json.loads(await request.post_body())
|
||||
data = await request.json()
|
||||
confirm = data.get("confirm")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -3,6 +3,26 @@
|
|||
=========
|
||||
Changelog
|
||||
=========
|
||||
.. _unreleased:
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
The big feature in this alpha is tools to **insert, edit and delete** rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.
|
||||
|
||||
The edit interface takes :ref:`custom column types <table_configuration_column_types>` into account. Plugins that define their own column types can use JavaScript to customize how those column types are presented in the edit interface.
|
||||
|
||||
- ``datasette.allowed_many()`` method for :ref:`resolving multiple permission checks at once <datasette_allowed_many>`. (:pr:`2775`)
|
||||
- Permission checks are now cached on a per-request basis, speeding up table pages with multiple plugins that check permissions in order to populate the :ref:`table actions menu <plugin_hook_table_actions>`.
|
||||
- Fixed a warning about ``gen.throw(*sys.exc_info())``. (:issue:`2776`)
|
||||
- New default custom column type ``textarea`` for multi-line text content. This is rendered as a ``<textarea>`` input in the edit UI.
|
||||
- The ``json`` column type now implements client-side validation in the edit UI.
|
||||
- The :ref:`makeColumnField() <javascript_plugins_makeColumnField>` JavaScript plugin hook allows plugins to define custom fields in the edit interface for their custom column types.
|
||||
- New UI for inserting, editing, and deleting rows within Datasette. (:issue:`2780`)
|
||||
- New ``/<database>/<table>/-/autocomplete?q=term`` :ref:`autocomplete JSON API <TableAutocompleteView>` for rapid autocomplete search against the contents of a table. This is used by the edit interface to select related rows for foreign keys. You can try it out on the ``/-/debug/autocomplete`` debug page.
|
||||
- New ``/<database>/<table>/-/fragment`` :ref:`HTML fragment endpoint <TableFragmentView>` for returning the HTML used to display a specific row.
|
||||
- ``await request.json()`` utility method for consuming the request body as JSON. (:issue:`2767`)
|
||||
- Database, table, query and row action menus can now be modified by plugins to :ref:`display buttons in addition to links <plugin_actions>`. (:issue:`2782`)
|
||||
|
||||
.. _v1_0_a33:
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ The object also has the following awaitable methods:
|
|||
``await request.post_vars()`` - dictionary
|
||||
Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead.
|
||||
|
||||
``await request.json()`` - Any
|
||||
Returns the parsed JSON body of a request submitted by ``POST``.
|
||||
|
||||
``await request.post_body()`` - bytes
|
||||
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
import datasette.views.table as table_views
|
||||
from datasette.database import QueryInterrupted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -192,7 +192,6 @@ async def test_autocomplete_primary_key_called_label():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
|
||||
monkeypatch.setattr(table_views, "AUTOCOMPLETE_TIME_LIMIT_MS", 1)
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
config={
|
||||
|
|
@ -217,16 +216,34 @@ async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
|
|||
def insert_rows(conn):
|
||||
conn.executemany(
|
||||
"insert into things (id, name) values (?, ?)",
|
||||
((f"item-{i:06d}", f"name {i:06d}") for i in range(200_000)),
|
||||
((f"item-1999{i:02d}", f"name 1999{i:02d}") for i in range(12)),
|
||||
)
|
||||
|
||||
await db.execute_write_fn(insert_rows)
|
||||
|
||||
original_execute = db.execute
|
||||
timeout_was_simulated = False
|
||||
|
||||
async def execute_with_simulated_timeout(sql, params=None, *args, **kwargs):
|
||||
nonlocal timeout_was_simulated
|
||||
if (
|
||||
not timeout_was_simulated
|
||||
and isinstance(params, dict)
|
||||
and params.get("q") == "item-1999"
|
||||
and "prefix_end" not in params
|
||||
):
|
||||
timeout_was_simulated = True
|
||||
raise QueryInterrupted(Exception("interrupted"), sql, params)
|
||||
return await original_execute(sql, params, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(db, "execute", execute_with_simulated_timeout)
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_timeout/things/-/autocomplete?q=item-1999"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert timeout_was_simulated
|
||||
data = response.json()
|
||||
assert data == {
|
||||
"rows": [
|
||||
|
|
|
|||
|
|
@ -55,6 +55,57 @@ async def test_request_post_body():
|
|||
assert data == json.loads(body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_json():
|
||||
scope = {
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"raw_path": b"/",
|
||||
"query_string": b"",
|
||||
"scheme": "http",
|
||||
"type": "http",
|
||||
"headers": [[b"content-type", b"application/json"]],
|
||||
}
|
||||
|
||||
data = {"hello": "world", "items": [1, 2, 3]}
|
||||
|
||||
async def receive():
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": json.dumps(data).encode("utf-8"),
|
||||
"more_body": False,
|
||||
}
|
||||
|
||||
request = Request(scope, receive)
|
||||
assert data == await request.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_json_invalid():
|
||||
scope = {
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"raw_path": b"/",
|
||||
"query_string": b"",
|
||||
"scheme": "http",
|
||||
"type": "http",
|
||||
"headers": [[b"content-type", b"application/json"]],
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": b"this is not JSON",
|
||||
"more_body": False,
|
||||
}
|
||||
|
||||
request = Request(scope, receive)
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
await request.json()
|
||||
|
||||
|
||||
def test_request_args():
|
||||
request = Request.fake("/foo?multi=1&multi=2&single=3")
|
||||
assert "1" == request.args.get("multi")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue