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.
- starlette_admin_infinite_scroll/__init__.py +4 -0
- starlette_admin_infinite_scroll/plugin.py +132 -0
- starlette_admin_infinite_scroll/py.typed +0 -0
- starlette_admin_infinite_scroll/static/plugins/infinite-scroll/css/infinite-scroll.css +27 -0
- starlette_admin_infinite_scroll/static/plugins/infinite-scroll/js/infinite-scroll.js +454 -0
- starlette_admin_infinite_scroll-0.1.0.dist-info/METADATA +107 -0
- starlette_admin_infinite_scroll-0.1.0.dist-info/RECORD +10 -0
- starlette_admin_infinite_scroll-0.1.0.dist-info/WHEEL +4 -0
- starlette_admin_infinite_scroll-0.1.0.dist-info/entry_points.txt +2 -0
- starlette_admin_infinite_scroll-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/starlette-admin-infinite-scroll/)
|
|
41
|
+
[](https://pypi.org/project/starlette-admin-infinite-scroll/)
|
|
42
|
+
[](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,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.
|