Coverage for src / lexigram / admin / ui / organisms / simple_pagination.py: 24%

34 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1"""Simple pagination component — no COUNT query required. 

2 

3Provides ``SimplePagination`` which renders only Previous/Next navigation 

4without a total page count. This is a critical performance optimisation 

5for large tables where ``COUNT(*)`` is expensive (FilamentPHP Y, React 

6Admin Y, Laravel Nova Y). 

7 

8The component uses a ``has_next_page`` boolean (set from the data layer 

9by fetching ``per_page + 1`` rows and checking if the extra row exists) 

10instead of a total count. 

11 

12Usage:: 

13 

14 rows = await db.fetch(limit=per_page + 1, offset=offset) 

15 has_next = len(rows) > per_page 

16 rows = rows[:per_page] 

17 

18 pager = SimplePagination( 

19 page=2, 

20 per_page=25, 

21 has_next_page=has_next, 

22 base_url="/admin/users", 

23 ) 

24""" 

25 

26from __future__ import annotations 

27 

28from typing import Any 

29 

30from lexigram.ui import Component, el 

31 

32 

33class SimplePagination(Component): 

34 """Prev / Next pagination with no total-count requirement. 

35 

36 Renders only a "Previous" button and a "Next" button, enabling 

37 fast pagination without an expensive ``COUNT(*)`` query. 

38 

39 Args: 

40 page: Current (1-based) page number. 

41 per_page: Items per page. 

42 has_next_page: Whether a next page exists. Set this by fetching 

43 ``per_page + 1`` rows and checking if more than ``per_page`` 

44 were returned. 

45 base_url: Base URL for page links. 

46 extra_query: Additional query string parameters to append (without 

47 leading ``&``). 

48 hx_target: HTMX target selector (default ``#main-content``). 

49 hx_push_url: Whether HTMX should push the URL (default ``"true"``). 

50 """ 

51 

52 def __init__( 

53 self, 

54 page: int = 1, 

55 per_page: int = 20, 

56 has_next_page: bool = False, 

57 base_url: str = "", 

58 extra_query: str = "", 

59 hx_target: str = "#main-content", 

60 hx_push_url: str = "true", 

61 **props: Any, 

62 ) -> None: 

63 super().__init__(**props) 

64 self.page = page 

65 self.per_page = per_page 

66 self.has_next_page = has_next_page 

67 self.base_url = base_url.rstrip("/") 

68 self.extra_query = f"&{extra_query}" if extra_query else "" 

69 self.hx_target = hx_target 

70 self.hx_push_url = hx_push_url 

71 

72 def render(self) -> Any: 

73 has_prev = self.page > 1 

74 prev_url = self._page_url(self.page - 1) 

75 next_url = self._page_url(self.page + 1) 

76 

77 prev_btn = self._nav_button( 

78 label="← Previous", 

79 url=prev_url, 

80 enabled=has_prev, 

81 ) 

82 next_btn = self._nav_button( 

83 label="Next →", 

84 url=next_url, 

85 enabled=self.has_next_page, 

86 ) 

87 page_label = el( 

88 "span", 

89 f"Page {self.page}", 

90 class_="text-sm text-muted-foreground px-3", 

91 ) 

92 

93 return el( 

94 "nav", 

95 prev_btn, 

96 page_label, 

97 next_btn, 

98 class_=( 

99 "flex items-center justify-between gap-2 py-3 border-t border-border" 

100 ), 

101 **{"aria-label": "Pagination"}, 

102 ) 

103 

104 # ------------------------------------------------------------------ 

105 # Private helpers 

106 # ------------------------------------------------------------------ 

107 

108 def _page_url(self, page: int) -> str: 

109 base = self.base_url or "." 

110 return f"{base}?page={page}&per_page={self.per_page}{self.extra_query}" 

111 

112 def _nav_button(self, label: str, url: str, enabled: bool) -> Any: 

113 disabled_cls = "opacity-50 cursor-not-allowed pointer-events-none" 

114 enabled_cls = "hover:bg-muted dark:hover:bg-muted" 

115 base_cls = ( 

116 "inline-flex items-center px-4 py-2 text-sm font-medium rounded-md " 

117 "border border-border " 

118 "bg-card " 

119 "text-foreground " 

120 "transition-colors" 

121 ) 

122 extra_cls = enabled_cls if enabled else disabled_cls 

123 

124 attrs: dict[str, Any] = { 

125 "class": f"{base_cls} {extra_cls}", 

126 } 

127 if enabled: 

128 attrs.update( 

129 { 

130 "href": url, 

131 "hx-get": url, 

132 "hx-target": self.hx_target, 

133 "hx-push-url": self.hx_push_url, 

134 } 

135 ) 

136 

137 tag = "a" if enabled else "span" 

138 return el(tag, label, **attrs)