Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/api/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/groundskeeping/contracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
LoadingView,
NavigationItem,
PageNavigation,
Pagination,
SectionItem,
SectionNavigation,
SelectionMode,
Expand All @@ -87,6 +88,7 @@
ViewAction,
ViewActionVariant,
WorkbenchLabels,
pagination_actions,
)
from groundskeeping.contracts.wizards import (
Choice,
Expand Down
56 changes: 55 additions & 1 deletion src/groundskeeping/contracts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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, ...]
Expand All @@ -119,6 +172,7 @@ class TableView:
status: SemanticStatus = SemanticStatus.INFO
message: str | None = None
actions: tuple[ViewAction, ...] = ()
pagination: Pagination | None = None


@dataclass(frozen=True)
Expand Down
4 changes: 3 additions & 1 deletion src/groundskeeping/widgets/workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_navigation_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from groundskeeping.contracts import (
PageRegistry,
PageRoute,
Pagination,
SectionItem,
SectionNavigation,
SelectionTableRow,
SelectionTableView,
TableView,
ViewAction,
pagination_actions,
)


Expand Down Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions tests/test_workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PageContext,
PageRegistration,
PageRoute,
Pagination,
SectionItem,
SectionNavigation,
SurfaceView,
Expand Down Expand Up @@ -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",
Expand All @@ -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()
Expand Down
Loading