Coverage for src / lexigram / admin / ui / molecules / date_hierarchy.py: 20%
61 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Date hierarchy filter — Django Admin-style year/month/day drill-down.
3Renders a breadcrumb-style date navigation bar that lets users drill into
4a dataset by year, then month, then day. Each level is a clickable link
5that adds the appropriate ``year=``, ``month=``, ``day=`` query parameters.
7Django Admin is the only framework with built-in date hierarchy; this
8brings that feature to lexigram-admin.
10Usage::
12 bar = DateHierarchyFilter(
13 field_name="created_at",
14 year=2026,
15 month=3,
16 day=None,
17 base_url="/admin/users",
18 resource_prefix="/admin/users",
19 )
20 html = bar.render()
21"""
23from __future__ import annotations
25import calendar
26from typing import Any
28from lexigram.ui import Component, el
30# Month name abbreviations (locale-neutral; translatable via i18n layer)
31_MONTH_NAMES = [
32 "Jan",
33 "Feb",
34 "Mar",
35 "Apr",
36 "May",
37 "Jun",
38 "Jul",
39 "Aug",
40 "Sep",
41 "Oct",
42 "Nov",
43 "Dec",
44]
47class DateHierarchyFilter(Component):
48 """Year/month/day drill-down filter navigation.
50 At each level the component renders quick-link buttons:
52 - **No selection**: Shows clickable year links (last 5 years + current).
53 - **Year selected**: Shows clickable month buttons (Jan–Dec).
54 - **Year + month selected**: Shows clickable day buttons for that month.
55 - **Year + month + day**: Shows breadcrumb with "×" clear button.
57 HTMX is used to reload the table without a full page refresh.
59 Args:
60 field_name: Model field being filtered (used in URL params as
61 ``{field_name}__year``, etc.).
62 year: Currently selected year, or ``None``.
63 month: Currently selected month (1–12), or ``None``.
64 day: Currently selected day (1–31), or ``None``.
65 base_url: Base URL for building drill-down links.
66 resource_prefix: HTMX target resource prefix.
67 available_years: Explicit list of years to show. If ``None``,
68 defaults to the 5 years before and including the current year
69 from the *year* argument or ``2026``.
70 """
72 def __init__(
73 self,
74 field_name: str = "created_at",
75 year: int | None = None,
76 month: int | None = None,
77 day: int | None = None,
78 base_url: str = "",
79 resource_prefix: str = "",
80 available_years: list[int] | None = None,
81 **props: Any,
82 ) -> None:
83 super().__init__(**props)
84 self.field_name = field_name
85 self.year = year
86 self.month = month
87 self.day = day
88 self.base_url = base_url.rstrip("/")
89 self.resource_prefix = (resource_prefix or base_url).rstrip("/")
90 _anchor = year or 2026
91 self.available_years = available_years or list(range(_anchor - 4, _anchor + 1))
93 # ------------------------------------------------------------------
94 # Public render
95 # ------------------------------------------------------------------
97 def render(self) -> Any:
98 items = self._build_items()
99 if not items:
100 return ""
102 return el(
103 "nav",
104 el(
105 "ol",
106 *items,
107 class_="flex flex-wrap items-center gap-1",
108 ),
109 class_=(
110 "flex items-center gap-2 text-sm "
111 "bg-card "
112 "border border-border "
113 "rounded-lg px-3 py-2 mb-3"
114 ),
115 **{"aria-label": "Date hierarchy"},
116 )
118 # ------------------------------------------------------------------
119 # Private helpers
120 # ------------------------------------------------------------------
122 def _htmx_link(self, label: str, url: str, extra_cls: str = "") -> Any:
123 """Render a single HTMX drill-down link."""
124 return el(
125 "a",
126 label,
127 href=url,
128 class_=(
129 f"px-2 py-0.5 rounded text-primary "
130 f"hover:bg-muted "
131 f"transition-colors cursor-pointer {extra_cls}"
132 ),
133 **{
134 "hx-get": url,
135 "hx-target": "#main-content",
136 "hx-push-url": "true",
137 },
138 )
140 def _clear_link(self) -> Any:
141 """Render an 'x clear' link that strips all date params."""
142 url = self.base_url or "?"
143 return self._htmx_link(
144 "\u00d7", url, extra_cls="text-muted-foreground hover:text-destructive"
145 )
147 def _build_url(
148 self,
149 *,
150 year: int | None = None,
151 month: int | None = None,
152 day: int | None = None,
153 ) -> str:
154 """Build a URL with the given date query params."""
155 params: list[str] = []
156 if year is not None:
157 params.append(f"{self.field_name}__year={year}")
158 if month is not None:
159 params.append(f"{self.field_name}__month={month}")
160 if day is not None:
161 params.append(f"{self.field_name}__day={day}")
162 base = self.base_url or "."
163 return f"{base}?{'&'.join(params)}" if params else base
165 def _build_items(self) -> list[Any]:
166 """Build the ordered list items based on current drill-down level."""
167 items: list[Any] = []
169 if self.year is None:
170 # Level 0 — show year buttons
171 for y in sorted(self.available_years, reverse=True):
172 url = self._build_url(year=y)
173 items.append(el("li", self._htmx_link(str(y), url)))
174 return items
176 # Level 1+ — always show year breadcrumb
177 items.append(
178 el(
179 "li",
180 el(
181 "span",
182 self._htmx_link(str(self.year), self._build_url(year=self.year)),
183 el("span", "/", class_="text-muted-foreground mx-1"),
184 class_="flex items-center",
185 ),
186 )
187 )
189 if self.month is None:
190 # Level 1 — show month buttons
191 for m_idx, m_name in enumerate(_MONTH_NAMES, start=1):
192 url = self._build_url(year=self.year, month=m_idx)
193 items.append(el("li", self._htmx_link(m_name, url)))
194 items.append(el("li", self._clear_link()))
195 return items
197 # Level 2 — year + month breadcrumb
198 items.append(
199 el(
200 "li",
201 el(
202 "span",
203 self._htmx_link(
204 _MONTH_NAMES[self.month - 1],
205 self._build_url(year=self.year, month=self.month),
206 ),
207 el("span", "/", class_="text-muted-foreground mx-1"),
208 class_="flex items-center",
209 ),
210 )
211 )
213 if self.day is None:
214 # Level 2 — show day buttons
215 _, days_in_month = calendar.monthrange(self.year, self.month)
216 for d in range(1, days_in_month + 1):
217 url = self._build_url(year=self.year, month=self.month, day=d)
218 items.append(el("li", self._htmx_link(str(d), url)))
219 items.append(el("li", self._clear_link()))
220 return items
222 # Level 3 — full breadcrumb + clear
223 items.append(
224 el(
225 "li",
226 el(
227 "span",
228 str(self.day),
229 class_="font-medium text-foreground",
230 ),
231 )
232 )
233 items.append(el("li", self._clear_link()))
234 return items