starlette-admin-infinite-scroll 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .plugin import InfiniteScrollConfig as InfiniteScrollConfig
4
+ from .plugin import InfiniteScrollPlugin as InfiniteScrollPlugin
@@ -0,0 +1,132 @@
1
+ """`InfiniteScrollPlugin`: The entry point end users list in `Admin(plugins=[...])`.
2
+
3
+ The plugin is purely client side. On list pages it injects one script that
4
+ watches the bottom of the list table and, when it scrolls into view, fetches
5
+ the next page of the regular server-rendered list route
6
+ (``/{key}/list?page=N``, keeping the current search/sort/filter/columns
7
+ query params), extracts its ``<tr data-sa-pk>`` rows and appends them to the
8
+ current table. No extra endpoint is needed: rows are rendered by the very
9
+ same ``_list_row.html`` template core uses.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import Sequence
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any
18
+
19
+ from starlette.datastructures import URL
20
+ from starlette.requests import Request
21
+ from starlette_admin.helpers import static_url
22
+ from starlette_admin.i18n import gettext as _
23
+ from starlette_admin.plugins import BasePlugin
24
+ from starlette_admin.types import RequestAction
25
+
26
+ _VERSION = "1"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class InfiniteScrollConfig:
31
+ """Options accepted by `InfiniteScrollPlugin(**options)`.
32
+
33
+ Attributes:
34
+ views: Identities (keys) of the model views that get infinite
35
+ scrolling. `None` (the default) enables it on every list view.
36
+ exclude_views: Identities of model views to leave untouched, applied
37
+ after `views`.
38
+ root_margin: `IntersectionObserver` root margin. The next page starts
39
+ loading when the bottom of the table is this close to the
40
+ viewport, so rows are usually ready before the user reaches them.
41
+ hide_pagination: Hide the page links and "go to page" form once
42
+ infinite scrolling is active. The "Showing x to y of z" summary and
43
+ the page size selector are kept.
44
+ max_auto_pages: Number of pages loaded automatically while scrolling
45
+ before switching to a "Load more" button. `0` means unlimited.
46
+ """
47
+
48
+ views: Sequence[str] | None = None
49
+ exclude_views: Sequence[str] = field(default_factory=tuple)
50
+ root_margin: str = "400px"
51
+ hide_pagination: bool = True
52
+ max_auto_pages: int = 0
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.max_auto_pages < 0:
56
+ raise ValueError("max_auto_pages must be >= 0")
57
+ # Normalize to tuples so the frozen config stays immutable.
58
+ if self.views is not None:
59
+ object.__setattr__(self, "views", tuple(self.views))
60
+ object.__setattr__(self, "exclude_views", tuple(self.exclude_views))
61
+
62
+ def is_enabled_for(self, key: str | None) -> bool:
63
+ if key is None:
64
+ return False
65
+ if self.views is not None and key not in self.views:
66
+ return False
67
+ return key not in self.exclude_views
68
+
69
+
70
+ class InfiniteScrollPlugin(BasePlugin):
71
+ """starlette-admin plugin: infinite scrolling for the list view."""
72
+
73
+ # Also the templates/static namespace: everything this plugin ships lives
74
+ # under "plugins/infinite-scroll/".
75
+ name = "infinite-scroll"
76
+
77
+ def __init__(self, **options: Any) -> None:
78
+ self.config = InfiniteScrollConfig(**options)
79
+
80
+ # -- Helpers ---------------------------------------------------------
81
+
82
+ def is_active(self, request: Request) -> bool:
83
+ """Whether the current request renders a list page that should get
84
+ infinite scrolling."""
85
+ if getattr(request.state, "action", None) != RequestAction.LIST:
86
+ return False
87
+ return self.config.is_enabled_for(request.path_params.get("key"))
88
+
89
+ def client_config(self, request: Request) -> dict[str, Any]:
90
+ """Options handed to the frontend script."""
91
+ route_name = request.app.state.ROUTE_NAME
92
+ key = request.path_params["key"]
93
+ return {
94
+ "rootMargin": self.config.root_margin,
95
+ "hidePagination": self.config.hide_pagination,
96
+ "maxAutoPages": self.config.max_auto_pages,
97
+ "actionUrl": str(request.url_for(f"{route_name}:action", key=key)),
98
+ "rowActionUrl": str(request.url_for(f"{route_name}:row-action", key=key)),
99
+ "i18n": {
100
+ "loading": _("Loading..."),
101
+ "loadMore": _("Load more"),
102
+ "retry": _("Retry"),
103
+ "error": _("Could not load more rows."),
104
+ "end": _("All rows loaded"),
105
+ },
106
+ }
107
+
108
+ # -- Declarative hooks -----------------------------------------------
109
+
110
+ def css_links(self, request: Request) -> list[str]:
111
+ if not self.is_active(request):
112
+ return []
113
+ return [
114
+ static_url(
115
+ request, "plugins/infinite-scroll/css/infinite-scroll.css", v=_VERSION
116
+ )
117
+ ]
118
+
119
+ def js_links(self, request: Request) -> list[str]:
120
+ if not self.is_active(request):
121
+ return []
122
+ # Plugin scripts are plain <script src> tags, so the options travel in
123
+ # the script URL and are read back through `document.currentScript`.
124
+ url = static_url(
125
+ request, "plugins/infinite-scroll/js/infinite-scroll.js", v=_VERSION
126
+ )
127
+ cfg = json.dumps(self.client_config(request), separators=(",", ":"))
128
+ return [str(URL(url).include_query_params(config=cfg))]
129
+
130
+ def template_globals(self) -> dict[str, Any]:
131
+ # Auto-prefixed by registration: templates read "infinite_scroll_config".
132
+ return {"config": asdict(self.config)}
File without changes
@@ -0,0 +1,27 @@
1
+ /* starlette-admin-infinite-scroll */
2
+
3
+ .sa-infinite-scroll-status {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ gap: 0.25rem;
8
+ min-height: 3rem;
9
+ padding: 0.75rem 1rem;
10
+ }
11
+
12
+ .sa-infinite-scroll-status:empty {
13
+ min-height: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ .sa-infinite-scroll-sentinel {
18
+ height: 1px;
19
+ width: 100%;
20
+ }
21
+
22
+ /* Page links and "go to page" are meaningless once pages are appended; the
23
+ "Showing x to y of z" summary and the page size selector stay. */
24
+ .sa-infinite-scroll--hide-pagination .card-footer .pagination,
25
+ .sa-infinite-scroll--hide-pagination .card-footer form {
26
+ display: none !important;
27
+ }
@@ -0,0 +1,454 @@
1
+ /**
2
+ * starlette-admin-infinite-scroll
3
+ *
4
+ * Turns the paginated list page into an infinitely scrolling one. When the
5
+ * bottom of the list table approaches the viewport, the next page of the
6
+ * regular list route is fetched (keeping every current query param: search,
7
+ * sort, filters, visible columns, page size), its `<tr data-sa-pk>` rows are
8
+ * extracted and appended to the current table.
9
+ *
10
+ * Rows come from core's own `_list_row.html`, and core list handlers
11
+ * (row selection, row click navigation, inline edit, confirmation actions)
12
+ * are delegated to the document, so appended rows behave like server
13
+ * rendered ones. Per-element bindings (tooltips, JSON viewers, search
14
+ * highlight, no-confirmation row actions) are re-applied to new rows here.
15
+ *
16
+ * Options are passed by the Python plugin as a JSON `config` query param on
17
+ * this script's URL.
18
+ */
19
+ (function () {
20
+ "use strict";
21
+
22
+ var DEFAULTS = {
23
+ rootMargin: "400px",
24
+ hidePagination: true,
25
+ maxAutoPages: 0,
26
+ actionUrl: "",
27
+ rowActionUrl: "",
28
+ i18n: {
29
+ loading: "Loading...",
30
+ loadMore: "Load more",
31
+ retry: "Retry",
32
+ error: "Could not load more rows.",
33
+ end: "All rows loaded",
34
+ },
35
+ };
36
+
37
+ // `document.currentScript` is only set while this file executes.
38
+ function readConfig() {
39
+ var cfg = {};
40
+ try {
41
+ var script = document.currentScript;
42
+ if (script && script.src) {
43
+ var raw = new URL(script.src, window.location.href).searchParams.get("config");
44
+ if (raw) cfg = JSON.parse(raw);
45
+ }
46
+ } catch (e) {
47
+ /* fall back to defaults */
48
+ }
49
+ var merged = Object.assign({}, DEFAULTS, cfg);
50
+ merged.i18n = Object.assign({}, DEFAULTS.i18n, cfg.i18n || {});
51
+ return merged;
52
+ }
53
+
54
+ var config = readConfig();
55
+
56
+ function getClass(name, fallback) {
57
+ var sa = window.StarletteAdmin;
58
+ return sa && typeof sa.getClass === "function" ? sa.getClass(name, fallback) : fallback;
59
+ }
60
+
61
+ function pageFromHref(href) {
62
+ if (!href) return null;
63
+ try {
64
+ var value = new URL(href, window.location.href).searchParams.get("page");
65
+ var n = parseInt(value || "1", 10);
66
+ return isNaN(n) ? null : n;
67
+ } catch (e) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** Replace the n-th (0-based) integer found in `text` with `value`. */
73
+ function replaceNthNumber(text, n, value) {
74
+ var i = -1;
75
+ return text.replace(/\d+/g, function (m) {
76
+ i += 1;
77
+ return i === n ? String(value) : m;
78
+ });
79
+ }
80
+
81
+ function nthNumber(text, n) {
82
+ var matches = (text || "").match(/\d+/g);
83
+ return matches && matches.length > n ? parseInt(matches[n], 10) : null;
84
+ }
85
+
86
+ function cssEscape(value) {
87
+ return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g, '\\"');
88
+ }
89
+
90
+ function InfiniteScroll(tbody) {
91
+ this.tbody = tbody;
92
+ this.table = tbody.closest("table");
93
+ this.card = this.table.closest(".card") || this.table.parentElement;
94
+ this.footer = this.card.querySelector(".card-footer");
95
+ this.summary = this.footer ? this.footer.querySelector("p") : null;
96
+ this.pagination = this.footer ? this.footer.querySelector(".pagination") : null;
97
+
98
+ var params = new URLSearchParams(window.location.search);
99
+ this.currentPage = parseInt(params.get("page") || "1", 10) || 1;
100
+ this.totalPages = this.detectTotalPages();
101
+ this.autoLoaded = 0;
102
+ this.loading = false;
103
+ this.done = false;
104
+ this.failed = false;
105
+ }
106
+
107
+ /**
108
+ * Total page count, read from the pagination's "Last" link (the last
109
+ * `.page-link`). Returns `null` when a theme renders no pagination, in
110
+ * which case pages are loaded until one yields no new rows.
111
+ */
112
+ InfiniteScroll.prototype.detectTotalPages = function () {
113
+ if (!this.pagination) return null;
114
+ var links = this.pagination.querySelectorAll("a.page-link[href]");
115
+ if (!links.length) return null;
116
+ return pageFromHref(links[links.length - 1].getAttribute("href"));
117
+ };
118
+
119
+ InfiniteScroll.prototype.hasMore = function () {
120
+ if (this.done) return false;
121
+ return this.totalPages === null || this.currentPage < this.totalPages;
122
+ };
123
+
124
+ InfiniteScroll.prototype.mount = function () {
125
+ if (!this.hasMore()) return;
126
+
127
+ this.card.classList.add("sa-infinite-scroll");
128
+ if (config.hidePagination) this.card.classList.add("sa-infinite-scroll--hide-pagination");
129
+
130
+ this.status = document.createElement("div");
131
+ this.status.className = "sa-infinite-scroll-status";
132
+ this.status.setAttribute("data-sa-hook", "infinite-scroll-status");
133
+ this.status.setAttribute("aria-live", "polite");
134
+
135
+ this.sentinel = document.createElement("div");
136
+ this.sentinel.className = "sa-infinite-scroll-sentinel";
137
+ this.sentinel.setAttribute("aria-hidden", "true");
138
+
139
+ var anchor = this.table.closest(".table-responsive") || this.table;
140
+ anchor.insertAdjacentElement("afterend", this.status);
141
+ this.status.insertAdjacentElement("afterend", this.sentinel);
142
+
143
+ this.initActionManager();
144
+ this.observe();
145
+ };
146
+
147
+ InfiniteScroll.prototype.observe = function () {
148
+ var self = this;
149
+ if (!("IntersectionObserver" in window)) {
150
+ // Very old browser: degrade to a manual "Load more" button.
151
+ this.renderButton(config.i18n.loadMore);
152
+ return;
153
+ }
154
+ this.observer = new IntersectionObserver(
155
+ function (entries) {
156
+ entries.forEach(function (entry) {
157
+ if (entry.isIntersecting) self.maybeAutoLoad();
158
+ });
159
+ },
160
+ { rootMargin: "0px 0px " + config.rootMargin + " 0px" }
161
+ );
162
+ this.observer.observe(this.sentinel);
163
+ };
164
+
165
+ InfiniteScroll.prototype.autoLimitReached = function () {
166
+ return config.maxAutoPages > 0 && this.autoLoaded >= config.maxAutoPages;
167
+ };
168
+
169
+ InfiniteScroll.prototype.maybeAutoLoad = function () {
170
+ if (this.loading || this.failed || !this.hasMore()) return;
171
+ if (this.autoLimitReached()) {
172
+ this.renderButton(config.i18n.loadMore);
173
+ return;
174
+ }
175
+ this.autoLoaded += 1;
176
+ this.loadNext();
177
+ };
178
+
179
+ /**
180
+ * After a page is appended the sentinel may still be in view (short pages,
181
+ * tall screens). The observer does not fire again for an element that
182
+ * stays visible, so re-check explicitly.
183
+ */
184
+ InfiniteScroll.prototype.recheck = function () {
185
+ if (!this.sentinel || !this.hasMore()) return;
186
+ var rect = this.sentinel.getBoundingClientRect();
187
+ var margin = parseInt(config.rootMargin, 10) || 0;
188
+ if (rect.top <= window.innerHeight + margin) this.maybeAutoLoad();
189
+ };
190
+
191
+ InfiniteScroll.prototype.nextUrl = function () {
192
+ var url = new URL(window.location.href);
193
+ url.searchParams.set("page", String(this.currentPage + 1));
194
+ url.hash = "";
195
+ return url.toString();
196
+ };
197
+
198
+ InfiniteScroll.prototype.loadNext = function () {
199
+ var self = this;
200
+ if (this.loading || !this.hasMore()) return Promise.resolve();
201
+ this.loading = true;
202
+ this.failed = false;
203
+ this.renderLoading();
204
+
205
+ return fetch(this.nextUrl(), {
206
+ credentials: "same-origin",
207
+ headers: { Accept: "text/html", "X-Requested-With": "XMLHttpRequest" },
208
+ })
209
+ .then(function (response) {
210
+ if (!response.ok) throw new Error("HTTP " + response.status);
211
+ return response.text();
212
+ })
213
+ .then(function (html) {
214
+ var doc = new DOMParser().parseFromString(html, "text/html");
215
+ var added = self.appendRows(doc);
216
+ self.currentPage += 1;
217
+ self.syncFromDocument(doc);
218
+ if (added === 0) self.done = true;
219
+ self.loading = false;
220
+ self.afterLoad();
221
+ })
222
+ .catch(function () {
223
+ self.loading = false;
224
+ self.failed = true;
225
+ self.renderError();
226
+ });
227
+ };
228
+
229
+ /** Append the rows of a fetched page, skipping pks already displayed. */
230
+ InfiniteScroll.prototype.appendRows = function (doc) {
231
+ var self = this;
232
+ var rows = Array.prototype.slice.call(doc.querySelectorAll("tbody tr[data-sa-pk]"));
233
+ var selectAll = document.querySelector('[data-sa-hook="select-all"]');
234
+ var selectAllChecked = !!(selectAll && selectAll.checked && !selectAll.indeterminate);
235
+ var fragment = document.createDocumentFragment();
236
+ var added = [];
237
+
238
+ rows.forEach(function (row) {
239
+ var pk = row.getAttribute("data-sa-pk");
240
+ if (self.tbody.querySelector('tr[data-sa-pk="' + cssEscape(pk) + '"]')) return;
241
+ var imported = document.importNode(row, true);
242
+ if (selectAllChecked) {
243
+ // Keep "all rows selected" (and "select all matching") consistent.
244
+ var box = imported.querySelector('[data-sa-hook="row-checkbox"]');
245
+ if (box) {
246
+ box.checked = true;
247
+ imported.setAttribute("data-sa-selected", "");
248
+ }
249
+ }
250
+ fragment.appendChild(imported);
251
+ added.push(imported);
252
+ });
253
+
254
+ if (!added.length) return 0;
255
+ this.tbody.appendChild(fragment);
256
+ added.forEach(function (row) {
257
+ self.bindRow(row);
258
+ });
259
+ this.refreshSelection();
260
+ this.updateSummary();
261
+ this.card.dispatchEvent(
262
+ new CustomEvent("sa:infinite-scroll:rows-appended", {
263
+ bubbles: true,
264
+ detail: { rows: added, page: this.currentPage + 1 },
265
+ })
266
+ );
267
+ return added.length;
268
+ };
269
+
270
+ /** Re-apply the per-element bindings core applies once on page load. */
271
+ InfiniteScroll.prototype.bindRow = function (row) {
272
+ if (typeof window.initTooltips === "function") window.initTooltips(row);
273
+ if (typeof window.initJsonViewers === "function" && window.jQuery && jQuery.fn.jsonViewer) {
274
+ window.initJsonViewers(jQuery(row).find("div.field-json[data-json]"));
275
+ }
276
+ if (this.actionManager) this.actionManager.initNoConfirmationActions(row);
277
+ this.highlight(row);
278
+ };
279
+
280
+ InfiniteScroll.prototype.highlight = function (row) {
281
+ if (typeof window.walkTextNodes !== "function") return;
282
+ var q = document.querySelector('input[name="q"]');
283
+ var term = q && q.value;
284
+ if (!term || !term.trim()) return;
285
+ var escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
286
+ var regex = new RegExp("(" + escaped + ")", "gi");
287
+ row.querySelectorAll("td").forEach(function (td) {
288
+ window.walkTextNodes(td, regex);
289
+ });
290
+ };
291
+
292
+ /**
293
+ * Core's `initRowSelection` keeps its state in a closure; its delegated
294
+ * `change` handler on row checkboxes recomputes everything (counter,
295
+ * select-all state, banner) from the live DOM, so poke it.
296
+ */
297
+ InfiniteScroll.prototype.refreshSelection = function () {
298
+ var box = this.tbody.querySelector('[data-sa-hook="row-checkbox"]');
299
+ if (box && window.jQuery) jQuery(box).trigger("change");
300
+ // "All N rows on this page are selected": now N is every loaded row.
301
+ var bannerPage = document.querySelector('[data-sa-hook="select-all-banner-page"]');
302
+ if (bannerPage && bannerPage.firstChild && bannerPage.firstChild.nodeType === Node.TEXT_NODE) {
303
+ var count = this.tbody.querySelectorAll("tr[data-sa-pk]").length;
304
+ bannerPage.firstChild.textContent = replaceNthNumber(bannerPage.firstChild.textContent, 0, count);
305
+ }
306
+ };
307
+
308
+ /** Rewrite "Showing x to y of z" so `y` covers every loaded row. */
309
+ InfiniteScroll.prototype.updateSummary = function () {
310
+ if (!this.summary) return;
311
+ var text = this.summary.textContent;
312
+ var start = nthNumber(text, 0);
313
+ var total = nthNumber(text, 2);
314
+ if (start === null) return;
315
+ var loaded = this.tbody.querySelectorAll("tr[data-sa-pk]").length;
316
+ var end = start + loaded - 1;
317
+ if (total !== null) end = Math.min(end, total);
318
+ this.summary.textContent = replaceNthNumber(text, 1, end);
319
+ };
320
+
321
+ /** Pick up a changed page count (rows created/deleted meanwhile). */
322
+ InfiniteScroll.prototype.syncFromDocument = function (doc) {
323
+ var links = doc.querySelectorAll(".card-footer .pagination a.page-link[href]");
324
+ if (links.length) {
325
+ var total = pageFromHref(links[links.length - 1].getAttribute("href"));
326
+ if (total !== null) this.totalPages = total;
327
+ }
328
+ };
329
+
330
+ InfiniteScroll.prototype.afterLoad = function () {
331
+ var self = this;
332
+ if (!this.hasMore()) {
333
+ this.finish();
334
+ return;
335
+ }
336
+ if (this.autoLimitReached()) {
337
+ this.renderButton(config.i18n.loadMore);
338
+ return;
339
+ }
340
+ this.clearStatus();
341
+ window.requestAnimationFrame(function () {
342
+ self.recheck();
343
+ });
344
+ };
345
+
346
+ InfiniteScroll.prototype.finish = function () {
347
+ this.done = true;
348
+ if (this.observer) this.observer.disconnect();
349
+ if (this.sentinel) this.sentinel.remove();
350
+ this.sentinel = null;
351
+ this.status.replaceChildren();
352
+ if (this.currentPage > 1) {
353
+ var end = document.createElement("span");
354
+ end.className = "text-muted small";
355
+ end.textContent = config.i18n.end;
356
+ this.status.appendChild(end);
357
+ }
358
+ };
359
+
360
+ InfiniteScroll.prototype.clearStatus = function () {
361
+ this.status.replaceChildren();
362
+ };
363
+
364
+ InfiniteScroll.prototype.renderLoading = function () {
365
+ var spinner;
366
+ var sa = window.StarletteAdmin;
367
+ try {
368
+ spinner = sa && typeof sa.template === "function" ? sa.template("spinner") : null;
369
+ } catch (e) {
370
+ spinner = null;
371
+ }
372
+ if (!spinner) {
373
+ spinner = document.createElement("span");
374
+ spinner.className = "spinner-border spinner-border-sm";
375
+ spinner.setAttribute("role", "status");
376
+ }
377
+ var label = document.createElement("span");
378
+ label.className = "ms-2 text-muted";
379
+ label.textContent = config.i18n.loading;
380
+ this.status.replaceChildren(spinner, label);
381
+ };
382
+
383
+ InfiniteScroll.prototype.renderButton = function (text) {
384
+ var self = this;
385
+ var button = document.createElement("button");
386
+ button.type = "button";
387
+ button.className = getClass("infinite_scroll.load_more_button", "btn btn-outline-secondary btn-sm");
388
+ button.setAttribute("data-sa-action", "infinite-scroll-load-more");
389
+ button.textContent = text;
390
+ button.addEventListener("click", function () {
391
+ self.loadNext();
392
+ });
393
+ this.status.replaceChildren(button);
394
+ };
395
+
396
+ InfiniteScroll.prototype.renderError = function () {
397
+ var message = document.createElement("span");
398
+ message.className = "text-danger me-2";
399
+ message.textContent = config.i18n.error;
400
+ this.status.replaceChildren(message);
401
+ var self = this;
402
+ var button = document.createElement("button");
403
+ button.type = "button";
404
+ button.className = getClass("infinite_scroll.retry_button", "btn btn-outline-danger btn-sm");
405
+ button.setAttribute("data-sa-action", "infinite-scroll-retry");
406
+ button.textContent = config.i18n.retry;
407
+ button.addEventListener("click", function () {
408
+ self.loadNext();
409
+ });
410
+ this.status.appendChild(button);
411
+ };
412
+
413
+ /**
414
+ * Rows appended here miss the per-element click bindings core's
415
+ * ActionManager puts on no-confirmation row actions. A dedicated instance
416
+ * handles them (row actions only need the row's pk); confirmation actions
417
+ * go through the shared modal and already work.
418
+ */
419
+ InfiniteScroll.prototype.initActionManager = function () {
420
+ if (typeof ActionManager === "undefined" || !config.rowActionUrl) return;
421
+ this.actionManager = new ActionManager(
422
+ config.actionUrl,
423
+ config.rowActionUrl,
424
+ function (query, element) {
425
+ query.append("pk", element.closest("tr").data("sa-pk"));
426
+ },
427
+ function (actionName, element, msg) {
428
+ if (typeof successAlert === "function") successAlert(msg);
429
+ window.location.reload();
430
+ },
431
+ function (actionName, element, error) {
432
+ if (typeof dangerAlert === "function") dangerAlert(error);
433
+ }
434
+ );
435
+ };
436
+
437
+ function init() {
438
+ var firstRow = document.querySelector("table tbody tr[data-sa-pk]");
439
+ if (!firstRow) return; // empty list: nothing to extend
440
+ var tbody = firstRow.closest("tbody");
441
+ if (tbody.hasAttribute("data-sa-infinite-scroll")) return; // idempotent
442
+ tbody.setAttribute("data-sa-infinite-scroll", "");
443
+ var instance = new InfiniteScroll(tbody);
444
+ instance.mount();
445
+ window.StarletteAdmin = window.StarletteAdmin || {};
446
+ window.StarletteAdmin.infiniteScroll = instance;
447
+ }
448
+
449
+ if (document.readyState === "loading") {
450
+ document.addEventListener("DOMContentLoaded", init);
451
+ } else {
452
+ init();
453
+ }
454
+ })();
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: starlette-admin-infinite-scroll
3
+ Version: 0.1.0
4
+ Summary: Infinite scrolling for the starlette-admin list view
5
+ Project-URL: Homepage, https://github.com/Alwinator/starlette-admin-infinite-scroll
6
+ Project-URL: Repository, https://github.com/Alwinator/starlette-admin-infinite-scroll
7
+ Project-URL: Documentation, https://github.com/Alwinator/starlette-admin-infinite-scroll/blob/main/docs/index.md
8
+ Project-URL: Issues, https://github.com/Alwinator/starlette-admin-infinite-scroll/issues
9
+ Project-URL: Changelog, https://github.com/Alwinator/starlette-admin-infinite-scroll/blob/main/CHANGELOG.md
10
+ Author-email: "Alwin J. Schuster" <contact@alwinschuster.at>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: admin,fastapi,infinite-scroll,plugin,starlette,starlette-admin
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Web Environment
16
+ Classifier: Framework :: AsyncIO
17
+ Classifier: Framework :: FastAPI
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: JavaScript
21
+ Classifier: Programming Language :: Python
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Programming Language :: Python :: 3.14
28
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
29
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.11
32
+ Requires-Dist: starlette-admin>=1.0
33
+ Provides-Extra: i18n
34
+ Requires-Dist: babel>=2.17.0; extra == 'i18n'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # starlette-admin-infinite-scroll
38
+
39
+ [![CI](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml/badge.svg)](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
43
+
44
+ Infinite scrolling for the [starlette-admin](https://github.com/jowilf/starlette-admin)
45
+ list view.
46
+
47
+ Scroll to the bottom of a list and the next page is appended to the table,
48
+ keeping the current search, sort, filters, visible columns and page size.
49
+ Appended rows are rendered by starlette-admin itself, so selection, bulk and
50
+ row actions, row click navigation and inline editing keep working.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install starlette-admin-infinite-scroll
56
+ ```
57
+
58
+ Requires Python 3.11+ and starlette-admin 1.0+.
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ from starlette_admin.contrib.sqla import Admin
64
+ from starlette_admin_infinite_scroll import InfiniteScrollPlugin
65
+
66
+ admin = Admin(engine, plugins=[InfiniteScrollPlugin()])
67
+ ```
68
+
69
+ Configure it through the constructor:
70
+
71
+ ```python
72
+ InfiniteScrollPlugin(
73
+ views=["product", "order"], # only these list views (default: all)
74
+ exclude_views=["audit-log"], # keep these paginated
75
+ root_margin="600px", # start loading earlier (default: "400px")
76
+ hide_pagination=True, # hide page links (default: True)
77
+ max_auto_pages=5, # then show "Load more" (default: 0 = unlimited)
78
+ )
79
+ ```
80
+
81
+ See [docs/index.md](docs/index.md) for details, behavior notes and the
82
+ JavaScript API.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ uv sync
88
+ uv run pytest
89
+ uv run python example/app.py # demo at http://localhost:8000/admin/product/list
90
+ ```
91
+
92
+ `make lint`, `make format`, `make test` and `make build` wrap the usual tasks.
93
+
94
+ ## Releasing
95
+
96
+ 1. Bump `__version__` in `src/starlette_admin_infinite_scroll/__init__.py`
97
+ and add an entry to [CHANGELOG.md](CHANGELOG.md).
98
+ 2. Commit and push to `main`.
99
+ 3. Create a GitHub release with the tag `v<version>` (e.g. `v0.2.0`).
100
+
101
+ Publishing the release runs [`publish.yml`](.github/workflows/publish.yml):
102
+ CI, a check that the tag matches the package version, the build, the upload
103
+ to PyPI via Trusted Publishing, and attaching the built files to the release.
104
+
105
+ ## License
106
+
107
+ [MIT](LICENSE)
@@ -0,0 +1,10 @@
1
+ starlette_admin_infinite_scroll/__init__.py,sha256=Rs4Fd6x7oDvrY7XR9QfIPWhDUj9DS7yJ_x8osGPcALA,153
2
+ starlette_admin_infinite_scroll/plugin.py,sha256=YSYrNRlVVuUjhI-nfpQVwdXuFQyr4Wp6C5hw7bdPu8k,5448
3
+ starlette_admin_infinite_scroll/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ starlette_admin_infinite_scroll/static/plugins/infinite-scroll/css/infinite-scroll.css,sha256=vXurC0QcC9_47TibJgw-BD52yZmxaspl64lPxT0aS-g,627
5
+ starlette_admin_infinite_scroll/static/plugins/infinite-scroll/js/infinite-scroll.js,sha256=nPdKkG-QK5IUGjtQgs6EOqVDiPJwkc_po_1gnvpZSPE,16119
6
+ starlette_admin_infinite_scroll-0.1.0.dist-info/METADATA,sha256=2C90aeHTefotiyt0EVeakQwIkihq8Rt9riw_AmM-VM8,4304
7
+ starlette_admin_infinite_scroll-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ starlette_admin_infinite_scroll-0.1.0.dist-info/entry_points.txt,sha256=duvOkKkCKbz7nZzb3y39DbtTPFL71Qmo0edJCATHSLw,97
9
+ starlette_admin_infinite_scroll-0.1.0.dist-info/licenses/LICENSE,sha256=f8elJ_j5O4HmoLpWt4g5CF06f_Lxq7MFLjzyTVqgQYc,1074
10
+ starlette_admin_infinite_scroll-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [starlette_admin.plugins]
2
+ infinite-scroll = starlette_admin_infinite_scroll:InfiniteScrollPlugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alwin J. Schuster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.