From fc0456e0b0d03a9ad3bdc1897339ea81d8e1c62d Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Fri, 11 Sep 2026 14:35:37 +1000 Subject: [PATCH] added pagination functionality --- docs/api/views.md | 19 ++++++++ src/groundskeeping/contracts/__init__.py | 2 + src/groundskeeping/contracts/views.py | 56 +++++++++++++++++++++++- src/groundskeeping/widgets/workbench.py | 4 +- tests/test_navigation_contracts.py | 37 ++++++++++++++++ tests/test_workbench.py | 18 +++++++- 6 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/api/views.md b/docs/api/views.md index acc4b00..adca63d 100644 --- a/docs/api/views.md +++ b/docs/api/views.md @@ -2,4 +2,23 @@ Immutable navigation, result, detail, status, and selection models rendered by the workbench. The [page guide](../pages.md#design-the-page-around-a-question) maps information shapes to useful views. +## Paginated tables + +Use `Pagination` when a page owns a bounded query result and needs to expose the result's position without making pagination a database or workbench concern: + +```python +pagination = Pagination(page=2, page_size=20, total_items=41) +view = TableView( + title="Mapping review", + columns=("Source", "Status"), + rows=rows, + pagination=pagination, + actions=pagination_actions(pagination), +) +``` + +`pagination_actions` supplies disabled-aware previous/next commands. The page still owns the handlers and the query; Groundskeeping only renders the metadata and stable table chrome. + +`TableRow.key` remains the stable row identity. Pages can use it to handle a selected row, open a wizard, or apply a later validation/acceptance action. The base `TableView` intentionally does not embed editable controls, so adding a specialised row editor later will not change the existing table or pagination contract. + ::: groundskeeping.contracts.views diff --git a/src/groundskeeping/contracts/__init__.py b/src/groundskeeping/contracts/__init__.py index 3b27ec9..d9bd44b 100644 --- a/src/groundskeeping/contracts/__init__.py +++ b/src/groundskeeping/contracts/__init__.py @@ -72,6 +72,7 @@ LoadingView, NavigationItem, PageNavigation, + Pagination, SectionItem, SectionNavigation, SelectionMode, @@ -87,6 +88,7 @@ ViewAction, ViewActionVariant, WorkbenchLabels, + pagination_actions, ) from groundskeeping.contracts.wizards import ( Choice, diff --git a/src/groundskeeping/contracts/views.py b/src/groundskeeping/contracts/views.py index 892b81d..f605523 100644 --- a/src/groundskeeping/contracts/views.py +++ b/src/groundskeeping/contracts/views.py @@ -53,6 +53,53 @@ class ViewAction: disabled: bool = False +@dataclass(frozen=True) +class Pagination: + """Page metadata for a bounded table result.""" + + page: int + page_size: int + total_items: int + + def __post_init__(self) -> None: + if self.page < 1: + raise ValueError("page must be positive") + if self.page_size < 1: + raise ValueError("page_size must be positive") + if self.total_items < 0: + raise ValueError("total_items cannot be negative") + + @property + def page_count(self) -> int: + """Return at least one page so empty results have a stable display.""" + return max(1, (self.total_items + self.page_size - 1) // self.page_size) + + @property + def has_previous(self) -> bool: + return self.page > 1 + + @property + def has_next(self) -> bool: + return self.page < self.page_count + + @property + def summary(self) -> str: + return f"Page {self.page} of {self.page_count} · {self.total_items} items" + + +def pagination_actions( + pagination: Pagination, + *, + previous_key: str = "pagination.previous", + next_key: str = "pagination.next", +) -> tuple[ViewAction, ...]: + """Return standard page-navigation actions for a table view.""" + return ( + ViewAction(previous_key, "Previous page", disabled=not pagination.has_previous), + ViewAction(next_key, "Next page", disabled=not pagination.has_next), + ) + + @dataclass(frozen=True) class CatalogueItem: """One navigable item in a page's catalogue tree.""" @@ -97,7 +144,13 @@ class CatalogueNavigation: @dataclass(frozen=True) class TableRow: - """One row in a selectable workbench table.""" + """One row in a selectable workbench table. + + Row interaction remains page-owned: ``key`` identifies the row for actions, + selection, and validation workflows, while ``detail`` may carry the payload + shown after selection. Inline editors are deliberately not part of the base + table contract; pages can open a wizard or use a selection table instead. + """ key: str cells: tuple[str, ...] @@ -119,6 +172,7 @@ class TableView: status: SemanticStatus = SemanticStatus.INFO message: str | None = None actions: tuple[ViewAction, ...] = () + pagination: Pagination | None = None @dataclass(frozen=True) diff --git a/src/groundskeeping/widgets/workbench.py b/src/groundskeeping/widgets/workbench.py index fdb7035..b63d7e9 100644 --- a/src/groundskeeping/widgets/workbench.py +++ b/src/groundskeeping/widgets/workbench.py @@ -365,7 +365,9 @@ def _prepare_table_surface(self, view: TableView) -> None: self.rows_table.styles.display = "block" self.set_status(view.status) self.set_summary(view.title, view.message) - self.query_one("#result-panel").border_subtitle = f"{len(view.rows)} rows" + self.query_one("#result-panel").border_subtitle = ( + view.pagination.summary if view.pagination is not None else f"{len(view.rows)} rows" + ) def _replace_table_rows(self, table: _WorkbenchDataTable, view: TableView) -> None: table.clear(columns=True) diff --git a/tests/test_navigation_contracts.py b/tests/test_navigation_contracts.py index cd5820c..a3d5c22 100644 --- a/tests/test_navigation_contracts.py +++ b/tests/test_navigation_contracts.py @@ -5,12 +5,14 @@ from groundskeeping.contracts import ( PageRegistry, PageRoute, + Pagination, SectionItem, SectionNavigation, SelectionTableRow, SelectionTableView, TableView, ViewAction, + pagination_actions, ) @@ -62,6 +64,41 @@ def test_surface_actions_are_commands_not_navigation_items() -> None: assert view.actions[0].key == "database.verify" +def test_pagination_contract_calculates_navigation_state() -> None: + pagination = Pagination(page=2, page_size=20, total_items=41) + + assert pagination.page_count == 3 + assert pagination.has_previous + assert pagination.has_next + assert pagination.summary == "Page 2 of 3 · 41 items" + + actions = pagination_actions(pagination) + assert [(action.key, action.disabled) for action in actions] == [ + ("pagination.previous", False), + ("pagination.next", False), + ] + + +def test_pagination_contract_keeps_empty_results_on_a_stable_page() -> None: + pagination = Pagination(page=1, page_size=20, total_items=0) + + assert pagination.page_count == 1 + assert not pagination.has_previous + assert not pagination.has_next + assert [action.disabled for action in pagination_actions(pagination)] == [True, True] + + +def test_pagination_contract_rejects_invalid_metadata() -> None: + with pytest.raises(ValueError, match="page must be positive"): + Pagination(page=0, page_size=20, total_items=1) + + with pytest.raises(ValueError, match="page_size must be positive"): + Pagination(page=1, page_size=0, total_items=1) + + with pytest.raises(ValueError, match="total_items cannot be negative"): + Pagination(page=1, page_size=20, total_items=-1) + + def test_selection_table_contract_carries_stable_selection_state() -> None: view = SelectionTableView( title="Vocabulary coverage", diff --git a/tests/test_workbench.py b/tests/test_workbench.py index 5432d3d..722aecd 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -12,6 +12,7 @@ PageContext, PageRegistration, PageRoute, + Pagination, SectionItem, SectionNavigation, SurfaceView, @@ -58,17 +59,27 @@ def row_selected(self, row_key: str, context: PageContext) -> None: return None -def _view(*rows: tuple[str, str]) -> TableView: +def _view( + *rows: tuple[str, str], pagination: Pagination | None = None +) -> TableView: return TableView( title="Jobs", columns=("Job", "State"), rows=tuple(TableRow(key, (key, state)) for key, state in rows), + pagination=pagination, ) def test_refresh_rows_preserves_cursor_and_handles_membership_changes() -> None: async def run() -> None: - page = _TablePage(_view(("a", "queued"), ("b", "running"), ("c", "done"))) + page = _TablePage( + _view( + ("a", "queued"), + ("b", "running"), + ("c", "done"), + pagination=Pagination(page=2, page_size=2, total_items=7), + ) + ) app = OperatorApp( OperatorAppSpec( app_id="workbench-refresh-test", @@ -79,6 +90,9 @@ async def run() -> None: ) async with app.run_test() as pilot: + assert app._workbench.query_one("#result-panel").border_subtitle == ( + "Page 2 of 4 · 7 items" + ) table = app._workbench.rows_table table.move_cursor(row=1, column=0, animate=False) await pilot.pause()