From e50985a95857045938eede7c47957eee144b2931 Mon Sep 17 00:00:00 2001 From: Andrew Hosgood Date: Tue, 12 May 2026 15:41:48 +0100 Subject: [PATCH] Add pagination functionality and support for pagination component in TNA Frontend --- CHANGELOG.md | 1 + docs/component.md | 57 ++++++ tests/test_component.py | 401 +++++++++++++++++++++++++++++++++++++ tna_utilities/component.py | 92 +++++++++ 4 files changed, 551 insertions(+) create mode 100644 docs/component.md create mode 100644 tests/test_component.py create mode 100644 tna_utilities/component.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e51a2d..a7b8d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added option in Flask Talisman to add Adobe Typekit CSP rules with `allow_typekit_content_security_policy=True` - Added `extra_headers` parameter in `Talisman` to update or add any global response headers +- New pagination functions for populating pagination components ### Changed diff --git a/docs/component.md b/docs/component.md new file mode 100644 index 0000000..8d6416a --- /dev/null +++ b/docs/component.md @@ -0,0 +1,57 @@ +# Component + +## `paginate()` + +Creates a pagination object in accordance with the [pages to show in a pagination component](https://design-system.nationalarchives.gov.uk/components/pagination/#number-of-page-links) in the National Archives Design System. + +### Arguments + +| Argument | Description | Default | +| -------------- | ---------------------------------------------------------- | ------- | +| `pages` | The total number of pages to paginate | [none] | +| `current_page` | The number of the current page | [none] | +| `around` | The number of items to always show around the current page | `1` | + +### Example + +```python +from tna_utilities.component import paginate + +print(paginate(42, 7)) +# [1, "...", 6, 7, 8, "...", 42] + +print(paginate(42, 7, around=2)) +# [1, "...", 5, 6, 7, 8, 9, "...", 42] +``` + +## `tna_frontend_pagination_items()` + +Creates an object that be used directly in a [National Archives pagination component](https://design-system.nationalarchives.gov.uk/components/pagination/). + +### Arguments + +| Argument | Description | Default | +| -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `pages` | The total number of pages to paginate | [none] | +| `current_page` | The number of the current page | [none] | +| `base_url` | The base URL including the blank query string for the page | [none] | +| `around` | The number of items to always show around the current page | `1` | +| `transformer` | A function to create the item given a number and whether it is the current page | `tna_utilities.component.tna_frontend_pagination_item_transformer` | +| `ellipsis` | A dictionary to use in place of an ellipsis | `{"ellipsis": True}` | + +### Example + +```python +from tna_utilities.component import paginate + +print(tna_frontend_pagination_items(42, 6, "?page=")) +# [ +# {"number": 1, "current": False, "href": "?page=1"}, +# {"ellipsis": True}, +# {"number": 5, "current": False, "href": "?page=5"}, +# {"number": 6, "current": True, "href": "?page=6"}, +# {"number": 7, "current": False, "href": "?page=7"}, +# {"ellipsis": True}, +# {"number": 42, "current": False, "href": "?page=42"}, +# ] +``` diff --git a/tests/test_component.py b/tests/test_component.py new file mode 100644 index 0000000..400d11d --- /dev/null +++ b/tests/test_component.py @@ -0,0 +1,401 @@ +import unittest + +from tna_utilities.component import ( + PAGINATION_GAP, + paginate, + tna_frontend_pagination_items, +) + + +class TestComponent(unittest.TestCase): + def test_pagination_first(self): + self.assertEqual( + paginate(42, 1), + [ + 1, + 2, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_second(self): + self.assertEqual( + paginate(42, 2), + [ + 1, + 2, + 3, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_third(self): + self.assertEqual( + paginate(42, 3), + [ + 1, + 2, + 3, + 4, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_fourth(self): + self.assertEqual( + paginate(42, 4), + [ + 1, + 2, + 3, + 4, + 5, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_fifth(self): + self.assertEqual( + paginate(42, 5), + [ + 1, + PAGINATION_GAP, + 4, + 5, + 6, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_sixth(self): + self.assertEqual( + paginate(42, 6), + [ + 1, + PAGINATION_GAP, + 5, + 6, + 7, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_fifth_from_last(self): + self.assertEqual( + paginate(42, 37), + [ + 1, + PAGINATION_GAP, + 36, + 37, + 38, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_four_from_last(self): + self.assertEqual( + paginate(42, 38), + [ + 1, + PAGINATION_GAP, + 37, + 38, + 39, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_three_from_last(self): + self.assertEqual( + paginate(42, 39), + [ + 1, + PAGINATION_GAP, + 38, + 39, + 40, + 41, + 42, + ], + ) + + def test_pagination_two_from_last(self): + self.assertEqual( + paginate(42, 40), + [ + 1, + PAGINATION_GAP, + 39, + 40, + 41, + 42, + ], + ) + + def test_pagination_one_from_last(self): + self.assertEqual( + paginate(42, 41), + [ + 1, + PAGINATION_GAP, + 40, + 41, + 42, + ], + ) + + def test_pagination_last(self): + self.assertEqual( + paginate(42, 42), + [ + 1, + PAGINATION_GAP, + 41, + 42, + ], + ) + + def test_pagination_first_larger_around(self): + self.assertEqual( + paginate(42, 1, around=3), + [ + 1, + 2, + 3, + 4, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_second_larger_around(self): + self.assertEqual( + paginate(42, 2, around=3), + [ + 1, + 2, + 3, + 4, + 5, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_third_larger_around(self): + self.assertEqual( + paginate(42, 3, around=3), + [ + 1, + 2, + 3, + 4, + 5, + 6, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_fourth_larger_around(self): + self.assertEqual( + paginate(42, 4, around=3), + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_fifth_larger_around(self): + self.assertEqual( + paginate(42, 5, around=3), + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_sixth_larger_around(self): + self.assertEqual( + paginate(42, 6, around=3), + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_seventh_larger_around(self): + self.assertEqual( + paginate(42, 7, around=3), + [ + 1, + PAGINATION_GAP, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_first_no_around(self): + self.assertEqual( + paginate(42, 1, around=0), + [ + 1, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_second_no_around(self): + self.assertEqual( + paginate(42, 2, around=0), + [ + 1, + 2, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_third_no_around(self): + self.assertEqual( + paginate(42, 3, around=0), + [ + 1, + PAGINATION_GAP, + 3, + PAGINATION_GAP, + 42, + ], + ) + + def test_pagination_no_pages(self): + with self.assertRaises(Exception): + paginate(0, 1) + + def test_pagination_invalid_pages(self): + with self.assertRaises(Exception): + paginate(None, 1) + + def test_pagination_negative_current_page(self): + with self.assertRaises(Exception): + paginate(42, -1) + + def test_pagination_negative_around(self): + with self.assertRaises(Exception): + paginate(42, 1, around=-1) + + def test_tna_pagination_items(self): + self.assertEqual( + tna_frontend_pagination_items(42, 6, "/test?page="), + [ + {"number": 1, "current": False, "href": "/test?page=1"}, + {"ellipsis": True}, + {"number": 5, "current": False, "href": "/test?page=5"}, + {"number": 6, "current": True, "href": "/test?page=6"}, + {"number": 7, "current": False, "href": "/test?page=7"}, + {"ellipsis": True}, + {"number": 42, "current": False, "href": "/test?page=42"}, + ], + ) + + def test_tna_pagination_items_custom_transformer(self): + def custom_transformer(item, current_page, base_url): + return { + "page": item, + "is_current": item == current_page, + "url": f"{base_url}{item}", + } + + self.assertEqual( + tna_frontend_pagination_items( + 42, 6, "/test?page=", transformer=custom_transformer + ), + [ + {"page": 1, "is_current": False, "url": "/test?page=1"}, + {"ellipsis": True}, + {"page": 5, "is_current": False, "url": "/test?page=5"}, + {"page": 6, "is_current": True, "url": "/test?page=6"}, + {"page": 7, "is_current": False, "url": "/test?page=7"}, + {"ellipsis": True}, + {"page": 42, "is_current": False, "url": "/test?page=42"}, + ], + ) + + def test_tna_pagination_items_custom_transformer_lambda(self): + self.assertEqual( + tna_frontend_pagination_items( + 42, + 6, + "/test?page=", + transformer=lambda item, current_page, base_url: { + "page": item, + "is_current": item == current_page, + "url": f"{base_url}{item}", + }, + ), + [ + {"page": 1, "is_current": False, "url": "/test?page=1"}, + {"ellipsis": True}, + {"page": 5, "is_current": False, "url": "/test?page=5"}, + {"page": 6, "is_current": True, "url": "/test?page=6"}, + {"page": 7, "is_current": False, "url": "/test?page=7"}, + {"ellipsis": True}, + {"page": 42, "is_current": False, "url": "/test?page=42"}, + ], + ) + + def test_tna_pagination_items_custom_ellipsis(self): + self.assertEqual( + tna_frontend_pagination_items( + 42, 6, "/test?page=", ellipsis={"number": None} + ), + [ + {"number": 1, "current": False, "href": "/test?page=1"}, + {"number": None}, + {"number": 5, "current": False, "href": "/test?page=5"}, + {"number": 6, "current": True, "href": "/test?page=6"}, + {"number": 7, "current": False, "href": "/test?page=7"}, + {"number": None}, + {"number": 42, "current": False, "href": "/test?page=42"}, + ], + ) diff --git a/tna_utilities/component.py b/tna_utilities/component.py new file mode 100644 index 0000000..be21c06 --- /dev/null +++ b/tna_utilities/component.py @@ -0,0 +1,92 @@ +from collections.abc import Callable + +PAGINATION_GAP = "..." + + +def paginate(pages: int, current_page: int, around: int = 1) -> list[int | str]: + """ + Paginate a list of items, highlighting the current page and adding gaps where appropriate. + + Args: + pages (int): The total number of pages to paginate. + current_page (int): The current page number. + around (int, optional): The number of pages to show around the current page. Defaults to 1. + Returns: + list: A list of dictionaries representing the paginated items, with "current" indicating the current page. + """ + + assert isinstance(pages, int), "pages must be an integer" + assert pages >= 1, "pages must be at least 1" + assert isinstance(current_page, int), "current_page must be an integer" + assert current_page >= 1, "current_page must be at least 1" + assert ( + current_page <= pages + ), "current_page cannot be greater than the number of pages" + assert around >= 0, "around must be non-negative" + + items = [item + 1 for item in range(pages)] + total = len(items) + + pagination = set() + pagination.add(1) + pagination.add(total) + + for i in range( + max(current_page - around, 1), min(current_page + around, total) + 1 + ): + pagination.add(i) + + sorted_pages = sorted(pagination) + + if around >= 1: + for i in range(len(sorted_pages) - 1): + if sorted_pages[i + 1] - sorted_pages[i] == 2: + pagination.add(sorted_pages[i] + 1) + + sorted_pages = sorted(pagination) + + result = [] + for i, page in enumerate(sorted_pages): + if i > 0 and page - sorted_pages[i - 1] > 1: + result.append(PAGINATION_GAP) + item = items[page - 1] + result.append(item) + + return result + + +def tna_frontend_pagination_items( + pages: int, + current_page: int, + base_url: str, + around: int = 1, + transformer: Callable[ + [int, int, str], dict + ] = lambda item, current_page, base_url: { + "number": item, + "current": item == current_page, + "href": f"{base_url}{item}", + }, + ellipsis: dict | None = None, +) -> list[dict]: + """ + Convert paginated items to a format suitable for the TNA frontend. + + Args: + pages (int): The total number of pages to paginate. + current_page (int): The current page number. + base_url (str): The base URL to use for pagination links. + around (int, optional): The number of pages to show around the current page. Defaults to 1. + transformer (callable, optional): A function to transform each page item. + ellipsis (dict, optional): A dictionary representing the ellipsis item. Defaults to {"ellipsis": True}. + Returns: + list: A list of dictionaries representing the paginated items for the TNA frontend, with "number" for page numbers, "current" for the current page, and "href" for pagination links. Gaps are represented with "ellipsis": True. + """ + + paginated_items = paginate(pages, current_page, around) + if ellipsis is None: + ellipsis = {"ellipsis": True} + return [ + (transformer(item, current_page, base_url) if type(item) is int else ellipsis) + for item in paginated_items + ]