starlette-admin-infinite-scroll 0.1.0__tar.gz
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-0.1.0/.gitignore +16 -0
- starlette_admin_infinite_scroll-0.1.0/CHANGELOG.md +24 -0
- starlette_admin_infinite_scroll-0.1.0/LICENSE +21 -0
- starlette_admin_infinite_scroll-0.1.0/PKG-INFO +107 -0
- starlette_admin_infinite_scroll-0.1.0/README.md +71 -0
- starlette_admin_infinite_scroll-0.1.0/docs/index.md +92 -0
- starlette_admin_infinite_scroll-0.1.0/example/app.py +81 -0
- starlette_admin_infinite_scroll-0.1.0/pyproject.toml +86 -0
- starlette_admin_infinite_scroll-0.1.0/src/starlette_admin_infinite_scroll/__init__.py +4 -0
- starlette_admin_infinite_scroll-0.1.0/src/starlette_admin_infinite_scroll/plugin.py +132 -0
- starlette_admin_infinite_scroll-0.1.0/src/starlette_admin_infinite_scroll/py.typed +0 -0
- starlette_admin_infinite_scroll-0.1.0/src/starlette_admin_infinite_scroll/static/plugins/infinite-scroll/css/infinite-scroll.css +27 -0
- starlette_admin_infinite_scroll-0.1.0/src/starlette_admin_infinite_scroll/static/plugins/infinite-scroll/js/infinite-scroll.js +454 -0
- starlette_admin_infinite_scroll-0.1.0/tests/conftest.py +94 -0
- starlette_admin_infinite_scroll-0.1.0/tests/test_plugin.py +98 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `InfiniteScrollPlugin` for starlette-admin 1.0: appends the next list page
|
|
14
|
+
when the bottom of the table approaches the viewport.
|
|
15
|
+
- Options: `views`, `exclude_views`, `root_margin`, `hide_pagination`,
|
|
16
|
+
`max_auto_pages`.
|
|
17
|
+
- "Load more" fallback, retry on error, duplicate-row protection, and support
|
|
18
|
+
for selection, row actions, tooltips, JSON viewers and search highlighting
|
|
19
|
+
on appended rows.
|
|
20
|
+
- `sa:infinite-scroll:rows-appended` DOM event and
|
|
21
|
+
`window.StarletteAdmin.infiniteScroll` JavaScript API.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/Alwinator/starlette-admin-infinite-scroll/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/Alwinator/starlette-admin-infinite-scroll/releases/tag/v0.1.0
|
|
@@ -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.
|
|
@@ -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,71 @@
|
|
|
1
|
+
# starlette-admin-infinite-scroll
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/starlette-admin-infinite-scroll/)
|
|
5
|
+
[](https://pypi.org/project/starlette-admin-infinite-scroll/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Infinite scrolling for the [starlette-admin](https://github.com/jowilf/starlette-admin)
|
|
9
|
+
list view.
|
|
10
|
+
|
|
11
|
+
Scroll to the bottom of a list and the next page is appended to the table,
|
|
12
|
+
keeping the current search, sort, filters, visible columns and page size.
|
|
13
|
+
Appended rows are rendered by starlette-admin itself, so selection, bulk and
|
|
14
|
+
row actions, row click navigation and inline editing keep working.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install starlette-admin-infinite-scroll
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.11+ and starlette-admin 1.0+.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from starlette_admin.contrib.sqla import Admin
|
|
28
|
+
from starlette_admin_infinite_scroll import InfiniteScrollPlugin
|
|
29
|
+
|
|
30
|
+
admin = Admin(engine, plugins=[InfiniteScrollPlugin()])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Configure it through the constructor:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
InfiniteScrollPlugin(
|
|
37
|
+
views=["product", "order"], # only these list views (default: all)
|
|
38
|
+
exclude_views=["audit-log"], # keep these paginated
|
|
39
|
+
root_margin="600px", # start loading earlier (default: "400px")
|
|
40
|
+
hide_pagination=True, # hide page links (default: True)
|
|
41
|
+
max_auto_pages=5, # then show "Load more" (default: 0 = unlimited)
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
See [docs/index.md](docs/index.md) for details, behavior notes and the
|
|
46
|
+
JavaScript API.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv sync
|
|
52
|
+
uv run pytest
|
|
53
|
+
uv run python example/app.py # demo at http://localhost:8000/admin/product/list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`make lint`, `make format`, `make test` and `make build` wrap the usual tasks.
|
|
57
|
+
|
|
58
|
+
## Releasing
|
|
59
|
+
|
|
60
|
+
1. Bump `__version__` in `src/starlette_admin_infinite_scroll/__init__.py`
|
|
61
|
+
and add an entry to [CHANGELOG.md](CHANGELOG.md).
|
|
62
|
+
2. Commit and push to `main`.
|
|
63
|
+
3. Create a GitHub release with the tag `v<version>` (e.g. `v0.2.0`).
|
|
64
|
+
|
|
65
|
+
Publishing the release runs [`publish.yml`](.github/workflows/publish.yml):
|
|
66
|
+
CI, a check that the tag matches the package version, the build, the upload
|
|
67
|
+
to PyPI via Trusted Publishing, and attaching the built files to the release.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Infinite Scroll
|
|
2
|
+
|
|
3
|
+
Infinite scrolling for the starlette-admin list view.
|
|
4
|
+
|
|
5
|
+
When the bottom of a list table approaches the viewport, the next page is
|
|
6
|
+
fetched from the regular list route (keeping the current search, sort,
|
|
7
|
+
filters, visible columns and page size) and its rows are appended to the
|
|
8
|
+
table. No extra endpoint is involved: rows are rendered by core's own
|
|
9
|
+
`_list_row.html`, so they look and behave exactly like server-rendered ones
|
|
10
|
+
(row selection, bulk and row actions, row click navigation, inline edit,
|
|
11
|
+
tooltips, JSON viewers, search highlighting).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install starlette-admin-infinite-scroll
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from starlette_admin_infinite_scroll import InfiniteScrollPlugin
|
|
23
|
+
from starlette_admin.contrib.sqla import Admin
|
|
24
|
+
|
|
25
|
+
admin = Admin(engine, plugins=[InfiniteScrollPlugin()])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That is the only integration point. Options are passed straight to the
|
|
29
|
+
constructor:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
InfiniteScrollPlugin(
|
|
33
|
+
views=["product", "order"], # only these list views
|
|
34
|
+
root_margin="600px", # start loading earlier
|
|
35
|
+
max_auto_pages=5, # then show a "Load more" button
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
| Option | Default | Description |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `views` | `None` | Identities of the model views that get infinite scrolling. `None` enables it on every list view. |
|
|
44
|
+
| `exclude_views` | `()` | Identities of model views to leave paginated, applied after `views`. |
|
|
45
|
+
| `root_margin` | `"400px"` | How close the bottom of the table must be to the viewport before the next page is requested. |
|
|
46
|
+
| `hide_pagination` | `True` | Hide the page links and the "go to page" form. The "Showing x to y of z" summary (kept up to date) and the page size selector stay. |
|
|
47
|
+
| `max_auto_pages` | `0` | Pages loaded automatically before switching to a "Load more" button. `0` means unlimited. |
|
|
48
|
+
|
|
49
|
+
The page size selector keeps working: pick a bigger page size to load more
|
|
50
|
+
rows per request. With "All", everything is on the first page and the plugin
|
|
51
|
+
stays idle.
|
|
52
|
+
|
|
53
|
+
## Behavior notes
|
|
54
|
+
|
|
55
|
+
- Scripts and styles are only injected on list pages of enabled views.
|
|
56
|
+
- Pages that do not fill the viewport keep loading until they do.
|
|
57
|
+
- Rows already displayed (for example after rows were created while
|
|
58
|
+
scrolling) are skipped, so a pk is never shown twice.
|
|
59
|
+
- When "select all" is checked, appended rows are selected too, so the
|
|
60
|
+
"select all matching" state stays consistent.
|
|
61
|
+
- A failed request shows an error with a "Retry" button.
|
|
62
|
+
- Reloading the page, or coming back from a detail page, starts over at the
|
|
63
|
+
page in the URL.
|
|
64
|
+
|
|
65
|
+
## JavaScript API
|
|
66
|
+
|
|
67
|
+
The instance is exposed as `window.StarletteAdmin.infiniteScroll`
|
|
68
|
+
(`loadNext()`, `currentPage`, `totalPages`). After each batch the card
|
|
69
|
+
element dispatches a bubbling `sa:infinite-scroll:rows-appended` event with
|
|
70
|
+
`detail.rows` (the appended `<tr>` elements) and `detail.page`, so custom list
|
|
71
|
+
templates can bind their own widgets:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
document.addEventListener("sa:infinite-scroll:rows-appended", (e) => {
|
|
75
|
+
e.detail.rows.forEach(initMyWidgets);
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Overriding assets
|
|
80
|
+
|
|
81
|
+
Every file this plugin ships lives under `plugins/infinite-scroll/`. Drop a
|
|
82
|
+
file at the matching path under your own `static_dir` to override it:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
your_project/
|
|
86
|
+
└── static/
|
|
87
|
+
└── plugins/infinite-scroll/css/infinite-scroll.css
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The status area uses the `.sa-infinite-scroll-status` class. The "Load more"
|
|
91
|
+
and "Retry" buttons resolve their classes through the theme `ClassMap` roles
|
|
92
|
+
`infinite_scroll.load_more_button` and `infinite_scroll.retry_button`.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Runnable demo for starlette-admin-infinite-scroll.
|
|
2
|
+
|
|
3
|
+
Mounts a SQLite-backed SQLAlchemy admin seeded with 500 products and the plugin
|
|
4
|
+
registered. Run `uv run python example/app.py` from the project root, then open
|
|
5
|
+
http://localhost:8000/admin/product/list.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
from sqlalchemy import Integer, create_engine, func, select
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
from starlette.responses import HTMLResponse
|
|
15
|
+
from starlette.routing import Route
|
|
16
|
+
from starlette_admin.contrib.sqla import Admin, ModelView
|
|
17
|
+
|
|
18
|
+
from starlette_admin_infinite_scroll import InfiniteScrollPlugin
|
|
19
|
+
|
|
20
|
+
engine = create_engine(
|
|
21
|
+
"sqlite:///example.sqlite", connect_args={"check_same_thread": False}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Base(DeclarativeBase):
|
|
26
|
+
"""Base class for all SQLAlchemy declarative models."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Product(Base):
|
|
30
|
+
__tablename__ = "products"
|
|
31
|
+
|
|
32
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
33
|
+
name: Mapped[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProductView(ModelView):
|
|
37
|
+
fields = [
|
|
38
|
+
"id",
|
|
39
|
+
"name",
|
|
40
|
+
]
|
|
41
|
+
searchable_fields = ["name"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def lifespan(app: Starlette):
|
|
46
|
+
Base.metadata.create_all(engine)
|
|
47
|
+
# Seed enough rows to scroll through several pages.
|
|
48
|
+
with Session(engine) as session:
|
|
49
|
+
if session.scalar(select(func.count(Product.id))) == 0:
|
|
50
|
+
session.add_all(Product(name=f"Product #{i:04d}") for i in range(1, 501))
|
|
51
|
+
session.commit()
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
app = Starlette(
|
|
56
|
+
lifespan=lifespan,
|
|
57
|
+
routes=[
|
|
58
|
+
Route(
|
|
59
|
+
"/",
|
|
60
|
+
lambda _: HTMLResponse('<a href="/admin/">Go to Admin →</a>'),
|
|
61
|
+
)
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
admin = Admin(
|
|
66
|
+
engine,
|
|
67
|
+
title="Infinite Scroll demo",
|
|
68
|
+
secret_key="dev-only-change-me",
|
|
69
|
+
plugins=[InfiniteScrollPlugin(root_margin="300px", max_auto_pages=5)],
|
|
70
|
+
debug=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
admin.add_view(ProductView(Product))
|
|
74
|
+
|
|
75
|
+
admin.mount_to(app)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
# Pass the app object (not an "app:app" import string) so the script works
|
|
80
|
+
# from any working directory.
|
|
81
|
+
uvicorn.run(app, port=8000)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starlette-admin-infinite-scroll"
|
|
7
|
+
description = "Infinite scrolling for the starlette-admin list view"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Alwin J. Schuster", email = "contact@alwinschuster.at" }]
|
|
13
|
+
keywords = ["starlette", "fastapi", "admin", "starlette-admin", "infinite-scroll", "plugin"]
|
|
14
|
+
# Single source of truth: `__version__` in src/starlette_admin_infinite_scroll/__init__.py
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Web Environment",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Framework :: FastAPI",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: JavaScript",
|
|
25
|
+
"Programming Language :: Python",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
dependencies = [
|
|
38
|
+
"starlette-admin >=1.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/Alwinator/starlette-admin-infinite-scroll"
|
|
43
|
+
Repository = "https://github.com/Alwinator/starlette-admin-infinite-scroll"
|
|
44
|
+
Documentation = "https://github.com/Alwinator/starlette-admin-infinite-scroll/blob/main/docs/index.md"
|
|
45
|
+
Issues = "https://github.com/Alwinator/starlette-admin-infinite-scroll/issues"
|
|
46
|
+
Changelog = "https://github.com/Alwinator/starlette-admin-infinite-scroll/blob/main/CHANGELOG.md"
|
|
47
|
+
|
|
48
|
+
[project.entry-points."starlette_admin.plugins"]
|
|
49
|
+
infinite-scroll = "starlette_admin_infinite_scroll:InfiniteScrollPlugin"
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
i18n = ["babel>=2.17.0"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.version]
|
|
55
|
+
path = "src/starlette_admin_infinite_scroll/__init__.py"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/starlette_admin_infinite_scroll"]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = [
|
|
62
|
+
"src/",
|
|
63
|
+
"tests/",
|
|
64
|
+
"docs/",
|
|
65
|
+
"example/",
|
|
66
|
+
"CHANGELOG.md",
|
|
67
|
+
"LICENSE",
|
|
68
|
+
"README.md",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[dependency-groups]
|
|
72
|
+
dev = [
|
|
73
|
+
"pytest>=8.0",
|
|
74
|
+
"pytest-asyncio>=0.24",
|
|
75
|
+
"httpx>=0.27",
|
|
76
|
+
"ruff>=0.8",
|
|
77
|
+
# starlette-admin's SQLAlchemy contrib targets 2.0.x.
|
|
78
|
+
"sqlalchemy[asyncio]>=2.0,<2.1",
|
|
79
|
+
"uvicorn[standard]>=0.30",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint]
|
|
83
|
+
select = ["E", "F", "I", "UP"]
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
asyncio_mode = "auto"
|
|
@@ -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,94 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy import Integer, create_engine
|
|
3
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
4
|
+
from sqlalchemy.pool import StaticPool
|
|
5
|
+
from starlette.applications import Starlette
|
|
6
|
+
from starlette.testclient import TestClient
|
|
7
|
+
from starlette_admin.contrib.sqla import Admin, ModelView
|
|
8
|
+
|
|
9
|
+
from starlette_admin_infinite_scroll import InfiniteScrollPlugin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Base(DeclarativeBase):
|
|
13
|
+
"""Base class for the in-memory demo model used across tests."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Product(Base):
|
|
17
|
+
__tablename__ = "products"
|
|
18
|
+
|
|
19
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
20
|
+
name: Mapped[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProductView(ModelView):
|
|
24
|
+
fields = [
|
|
25
|
+
"id",
|
|
26
|
+
"name",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CsrfTestClient(TestClient):
|
|
31
|
+
"""Drop-in `TestClient` replacement that automatically handles CSRF.
|
|
32
|
+
|
|
33
|
+
`BaseAdmin` always attaches CSRF middleware, so a plain `TestClient` POST is
|
|
34
|
+
rejected with a 403. This client fetches the admin index on construction to
|
|
35
|
+
obtain the signed `starlette_admin_csrftoken` cookie, then sends it as
|
|
36
|
+
`X-CSRFToken` on every mutating request.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, app, **kwargs):
|
|
40
|
+
super().__init__(app, **kwargs)
|
|
41
|
+
self.get("/admin/")
|
|
42
|
+
self._csrf_token = self.cookies.get("starlette_admin_csrftoken", "")
|
|
43
|
+
|
|
44
|
+
def _with_csrf_header(self, kwargs: dict) -> dict:
|
|
45
|
+
headers = dict(kwargs.pop("headers", None) or {})
|
|
46
|
+
headers.setdefault("X-CSRFToken", self._csrf_token)
|
|
47
|
+
kwargs["headers"] = headers
|
|
48
|
+
return kwargs
|
|
49
|
+
|
|
50
|
+
def post(self, url, **kwargs):
|
|
51
|
+
return super().post(url, **self._with_csrf_header(kwargs))
|
|
52
|
+
|
|
53
|
+
def put(self, url, **kwargs):
|
|
54
|
+
return super().put(url, **self._with_csrf_header(kwargs))
|
|
55
|
+
|
|
56
|
+
def delete(self, url, **kwargs):
|
|
57
|
+
return super().delete(url, **self._with_csrf_header(kwargs))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def plugin():
|
|
62
|
+
return InfiniteScrollPlugin()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def engine():
|
|
67
|
+
engine = create_engine(
|
|
68
|
+
"sqlite:///:memory:",
|
|
69
|
+
connect_args={"check_same_thread": False},
|
|
70
|
+
poolclass=StaticPool,
|
|
71
|
+
)
|
|
72
|
+
Base.metadata.create_all(engine)
|
|
73
|
+
return engine
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def seed(engine):
|
|
78
|
+
with Session(engine) as session:
|
|
79
|
+
session.add_all(Product(id=i, name=f"Product {i}") for i in range(1, 46))
|
|
80
|
+
session.commit()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture
|
|
84
|
+
def admin(plugin, engine):
|
|
85
|
+
admin = Admin(engine, secret_key="test", plugins=[plugin])
|
|
86
|
+
admin.add_view(ProductView(Product))
|
|
87
|
+
return admin
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.fixture
|
|
91
|
+
def client(admin):
|
|
92
|
+
app = Starlette()
|
|
93
|
+
admin.mount_to(app)
|
|
94
|
+
return CsrfTestClient(app, base_url="http://testserver")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import parse_qs, urlparse
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from starlette_admin_infinite_scroll import InfiniteScrollConfig, InfiniteScrollPlugin
|
|
8
|
+
|
|
9
|
+
SCRIPT_RE = re.compile(
|
|
10
|
+
r'<script[^>]+src="([^"]*plugins/infinite-scroll/js/infinite-scroll\.js[^"]*)"'
|
|
11
|
+
)
|
|
12
|
+
CSS_PATH = "plugins/infinite-scroll/css/infinite-scroll.css"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _script_config(html: str) -> dict:
|
|
16
|
+
match = SCRIPT_RE.search(html)
|
|
17
|
+
assert match, "infinite-scroll script not injected"
|
|
18
|
+
src = match.group(1).replace("&", "&")
|
|
19
|
+
return json.loads(parse_qs(urlparse(src).query)["config"][0])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_plugin_registers_under_its_name(admin, plugin):
|
|
23
|
+
assert admin.plugins == {"infinite-scroll": plugin}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_assets_injected_on_list_page(client):
|
|
27
|
+
response = client.get("/admin/product/list")
|
|
28
|
+
assert response.status_code == 200
|
|
29
|
+
assert SCRIPT_RE.search(response.text)
|
|
30
|
+
assert CSS_PATH in response.text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("url", ["/admin/", "/admin/product/create"])
|
|
34
|
+
def test_assets_not_injected_outside_list_page(client, url):
|
|
35
|
+
response = client.get(url)
|
|
36
|
+
assert response.status_code == 200
|
|
37
|
+
assert not SCRIPT_RE.search(response.text)
|
|
38
|
+
assert CSS_PATH not in response.text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_script_config_defaults(client):
|
|
42
|
+
cfg = _script_config(client.get("/admin/product/list").text)
|
|
43
|
+
assert cfg["rootMargin"] == "400px"
|
|
44
|
+
assert cfg["hidePagination"] is True
|
|
45
|
+
assert cfg["maxAutoPages"] == 0
|
|
46
|
+
assert cfg["rowActionUrl"].endswith("/product/row-action")
|
|
47
|
+
assert cfg["actionUrl"].endswith("/product/action")
|
|
48
|
+
assert set(cfg["i18n"]) == {"loading", "loadMore", "retry", "error", "end"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize(
|
|
52
|
+
"plugin",
|
|
53
|
+
[
|
|
54
|
+
InfiniteScrollPlugin(
|
|
55
|
+
root_margin="1000px", hide_pagination=False, max_auto_pages=3
|
|
56
|
+
)
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_script_config_options(client):
|
|
60
|
+
cfg = _script_config(client.get("/admin/product/list").text)
|
|
61
|
+
assert cfg["rootMargin"] == "1000px"
|
|
62
|
+
assert cfg["hidePagination"] is False
|
|
63
|
+
assert cfg["maxAutoPages"] == 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("plugin", [InfiniteScrollPlugin(views=["other"])])
|
|
67
|
+
def test_views_allowlist(client):
|
|
68
|
+
assert not SCRIPT_RE.search(client.get("/admin/product/list").text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("plugin", [InfiniteScrollPlugin(exclude_views=["product"])])
|
|
72
|
+
def test_exclude_views(client):
|
|
73
|
+
assert not SCRIPT_RE.search(client.get("/admin/product/list").text)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_static_assets_served(client):
|
|
77
|
+
js = client.get("/admin/static/plugins/infinite-scroll/js/infinite-scroll.js")
|
|
78
|
+
css = client.get(f"/admin/static/{CSS_PATH}")
|
|
79
|
+
assert js.status_code == 200
|
|
80
|
+
assert "IntersectionObserver" in js.text
|
|
81
|
+
assert css.status_code == 200
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_next_page_contains_rows(client, seed):
|
|
85
|
+
"""The script relies on the plain list route rendering `tr[data-sa-pk]`."""
|
|
86
|
+
html = client.get("/admin/product/list?page=2&page_size=10").text
|
|
87
|
+
pks = re.findall(r'<tr data-sa-pk="(\d+)"', html)
|
|
88
|
+
assert pks == [str(i) for i in range(11, 21)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_config_validation():
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
InfiniteScrollConfig(max_auto_pages=-1)
|
|
94
|
+
cfg = InfiniteScrollConfig(views=["a", "b"], exclude_views=["b"])
|
|
95
|
+
assert cfg.is_enabled_for("a")
|
|
96
|
+
assert not cfg.is_enabled_for("b")
|
|
97
|
+
assert not cfg.is_enabled_for("c")
|
|
98
|
+
assert not cfg.is_enabled_for(None)
|