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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ .venv/
9
+ venv/
10
+ dist/
11
+ build/
12
+ *.sqlite
13
+ *.sqlite3
14
+ .coverage
15
+ htmlcov/
16
+ .env
@@ -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
+ [![CI](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml/badge.svg)](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
43
+
44
+ Infinite scrolling for the [starlette-admin](https://github.com/jowilf/starlette-admin)
45
+ list view.
46
+
47
+ Scroll to the bottom of a list and the next page is appended to the table,
48
+ keeping the current search, sort, filters, visible columns and page size.
49
+ Appended rows are rendered by starlette-admin itself, so selection, bulk and
50
+ row actions, row click navigation and inline editing keep working.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install starlette-admin-infinite-scroll
56
+ ```
57
+
58
+ Requires Python 3.11+ and starlette-admin 1.0+.
59
+
60
+ ## Usage
61
+
62
+ ```python
63
+ from starlette_admin.contrib.sqla import Admin
64
+ from starlette_admin_infinite_scroll import InfiniteScrollPlugin
65
+
66
+ admin = Admin(engine, plugins=[InfiniteScrollPlugin()])
67
+ ```
68
+
69
+ Configure it through the constructor:
70
+
71
+ ```python
72
+ InfiniteScrollPlugin(
73
+ views=["product", "order"], # only these list views (default: all)
74
+ exclude_views=["audit-log"], # keep these paginated
75
+ root_margin="600px", # start loading earlier (default: "400px")
76
+ hide_pagination=True, # hide page links (default: True)
77
+ max_auto_pages=5, # then show "Load more" (default: 0 = unlimited)
78
+ )
79
+ ```
80
+
81
+ See [docs/index.md](docs/index.md) for details, behavior notes and the
82
+ JavaScript API.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ uv sync
88
+ uv run pytest
89
+ uv run python example/app.py # demo at http://localhost:8000/admin/product/list
90
+ ```
91
+
92
+ `make lint`, `make format`, `make test` and `make build` wrap the usual tasks.
93
+
94
+ ## Releasing
95
+
96
+ 1. Bump `__version__` in `src/starlette_admin_infinite_scroll/__init__.py`
97
+ and add an entry to [CHANGELOG.md](CHANGELOG.md).
98
+ 2. Commit and push to `main`.
99
+ 3. Create a GitHub release with the tag `v<version>` (e.g. `v0.2.0`).
100
+
101
+ Publishing the release runs [`publish.yml`](.github/workflows/publish.yml):
102
+ CI, a check that the tag matches the package version, the build, the upload
103
+ to PyPI via Trusted Publishing, and attaching the built files to the release.
104
+
105
+ ## License
106
+
107
+ [MIT](LICENSE)
@@ -0,0 +1,71 @@
1
+ # starlette-admin-infinite-scroll
2
+
3
+ [![CI](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml/badge.svg)](https://github.com/Alwinator/starlette-admin-infinite-scroll/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/starlette-admin-infinite-scroll.svg)](https://pypi.org/project/starlette-admin-infinite-scroll/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 &rarr;</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,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .plugin import InfiniteScrollConfig as InfiniteScrollConfig
4
+ from .plugin import InfiniteScrollPlugin as InfiniteScrollPlugin
@@ -0,0 +1,132 @@
1
+ """`InfiniteScrollPlugin`: The entry point end users list in `Admin(plugins=[...])`.
2
+
3
+ The plugin is purely client side. On list pages it injects one script that
4
+ watches the bottom of the list table and, when it scrolls into view, fetches
5
+ the next page of the regular server-rendered list route
6
+ (``/{key}/list?page=N``, keeping the current search/sort/filter/columns
7
+ query params), extracts its ``<tr data-sa-pk>`` rows and appends them to the
8
+ current table. No extra endpoint is needed: rows are rendered by the very
9
+ same ``_list_row.html`` template core uses.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import Sequence
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any
18
+
19
+ from starlette.datastructures import URL
20
+ from starlette.requests import Request
21
+ from starlette_admin.helpers import static_url
22
+ from starlette_admin.i18n import gettext as _
23
+ from starlette_admin.plugins import BasePlugin
24
+ from starlette_admin.types import RequestAction
25
+
26
+ _VERSION = "1"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class InfiniteScrollConfig:
31
+ """Options accepted by `InfiniteScrollPlugin(**options)`.
32
+
33
+ Attributes:
34
+ views: Identities (keys) of the model views that get infinite
35
+ scrolling. `None` (the default) enables it on every list view.
36
+ exclude_views: Identities of model views to leave untouched, applied
37
+ after `views`.
38
+ root_margin: `IntersectionObserver` root margin. The next page starts
39
+ loading when the bottom of the table is this close to the
40
+ viewport, so rows are usually ready before the user reaches them.
41
+ hide_pagination: Hide the page links and "go to page" form once
42
+ infinite scrolling is active. The "Showing x to y of z" summary and
43
+ the page size selector are kept.
44
+ max_auto_pages: Number of pages loaded automatically while scrolling
45
+ before switching to a "Load more" button. `0` means unlimited.
46
+ """
47
+
48
+ views: Sequence[str] | None = None
49
+ exclude_views: Sequence[str] = field(default_factory=tuple)
50
+ root_margin: str = "400px"
51
+ hide_pagination: bool = True
52
+ max_auto_pages: int = 0
53
+
54
+ def __post_init__(self) -> None:
55
+ if self.max_auto_pages < 0:
56
+ raise ValueError("max_auto_pages must be >= 0")
57
+ # Normalize to tuples so the frozen config stays immutable.
58
+ if self.views is not None:
59
+ object.__setattr__(self, "views", tuple(self.views))
60
+ object.__setattr__(self, "exclude_views", tuple(self.exclude_views))
61
+
62
+ def is_enabled_for(self, key: str | None) -> bool:
63
+ if key is None:
64
+ return False
65
+ if self.views is not None and key not in self.views:
66
+ return False
67
+ return key not in self.exclude_views
68
+
69
+
70
+ class InfiniteScrollPlugin(BasePlugin):
71
+ """starlette-admin plugin: infinite scrolling for the list view."""
72
+
73
+ # Also the templates/static namespace: everything this plugin ships lives
74
+ # under "plugins/infinite-scroll/".
75
+ name = "infinite-scroll"
76
+
77
+ def __init__(self, **options: Any) -> None:
78
+ self.config = InfiniteScrollConfig(**options)
79
+
80
+ # -- Helpers ---------------------------------------------------------
81
+
82
+ def is_active(self, request: Request) -> bool:
83
+ """Whether the current request renders a list page that should get
84
+ infinite scrolling."""
85
+ if getattr(request.state, "action", None) != RequestAction.LIST:
86
+ return False
87
+ return self.config.is_enabled_for(request.path_params.get("key"))
88
+
89
+ def client_config(self, request: Request) -> dict[str, Any]:
90
+ """Options handed to the frontend script."""
91
+ route_name = request.app.state.ROUTE_NAME
92
+ key = request.path_params["key"]
93
+ return {
94
+ "rootMargin": self.config.root_margin,
95
+ "hidePagination": self.config.hide_pagination,
96
+ "maxAutoPages": self.config.max_auto_pages,
97
+ "actionUrl": str(request.url_for(f"{route_name}:action", key=key)),
98
+ "rowActionUrl": str(request.url_for(f"{route_name}:row-action", key=key)),
99
+ "i18n": {
100
+ "loading": _("Loading..."),
101
+ "loadMore": _("Load more"),
102
+ "retry": _("Retry"),
103
+ "error": _("Could not load more rows."),
104
+ "end": _("All rows loaded"),
105
+ },
106
+ }
107
+
108
+ # -- Declarative hooks -----------------------------------------------
109
+
110
+ def css_links(self, request: Request) -> list[str]:
111
+ if not self.is_active(request):
112
+ return []
113
+ return [
114
+ static_url(
115
+ request, "plugins/infinite-scroll/css/infinite-scroll.css", v=_VERSION
116
+ )
117
+ ]
118
+
119
+ def js_links(self, request: Request) -> list[str]:
120
+ if not self.is_active(request):
121
+ return []
122
+ # Plugin scripts are plain <script src> tags, so the options travel in
123
+ # the script URL and are read back through `document.currentScript`.
124
+ url = static_url(
125
+ request, "plugins/infinite-scroll/js/infinite-scroll.js", v=_VERSION
126
+ )
127
+ cfg = json.dumps(self.client_config(request), separators=(",", ":"))
128
+ return [str(URL(url).include_query_params(config=cfg))]
129
+
130
+ def template_globals(self) -> dict[str, Any]:
131
+ # Auto-prefixed by registration: templates read "infinite_scroll_config".
132
+ return {"config": asdict(self.config)}
@@ -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("&amp;", "&")
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)