Coverage for src/lexigram/admin/settings/panel/ui.py: 22%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""UI components for configuration dashboard."""
3from __future__ import annotations
5from typing import Any
7from lexigram.admin.settings.panel.nodes import ConfigSpec
8from lexigram.ui import (
9 Card,
10 Component,
11 FieldSchema,
12 Form,
13 FormActions,
14 NumberInput,
15 Select,
16 Stack,
17 TextInput,
18 Toggle,
19 el,
20)
22__all__ = ["ConfigDashboardUI"]
25class BooleanField(Component):
26 """Toggle with a hidden false input so unchecked states persist."""
28 def __init__(
29 self,
30 name: str,
31 value: bool,
32 label: str | None = None,
33 ) -> None:
34 super().__init__()
35 self.name = name
36 self.value = value
37 self.label = label
39 def render(self) -> Any:
40 return el(
41 "div",
42 Toggle(
43 name=self.name,
44 value="true",
45 checked=self.value,
46 label=self.label,
47 ),
48 el("input", type="hidden", name=self.name, value="false"),
49 class_="flex flex-col",
50 id=f"{self.name}-field",
51 )
54class ConfigDashboardUI:
55 """UI helper for configuration dashboard."""
57 def render_dashboard(
58 self,
59 category: str,
60 specs: list[ConfigSpec],
61 active_ns: str | None,
62 active_spec: dict[str, Any] | None,
63 values: dict[str, Any],
64 state: Any = None,
65 ) -> Any:
66 """Render the complete dashboard content."""
67 return Stack(
68 gap=6,
69 children=[
70 self.render_header(category),
71 el(
72 "div",
73 self.render_sidebar(specs, active_ns, category),
74 self.render_main_content(active_spec, values, active_ns)
75 if active_spec and active_ns
76 else self.render_empty_state(),
77 class_="flex flex-col lg:flex-row gap-6 items-start",
78 ),
79 ],
80 )
82 def render_header(self, category: str) -> Any:
83 """Render dashboard header."""
84 title_map = {
85 "env": "Environment Variables",
86 "admin": "Admin Settings",
87 "app": "Application Config",
88 }
90 return el(
91 "div",
92 [
93 el(
94 "h1",
95 title_map.get(category, "Configuration"),
96 class_="text-3xl font-bold text-foreground",
97 ),
98 el(
99 "p",
100 "Manage your system configuration and environment settings",
101 class_="text-muted-foreground mt-2",
102 ),
103 ],
104 )
106 def render_sidebar(
107 self,
108 specs: list[ConfigSpec],
109 active_ns: str | None,
110 category: str,
111 ) -> Any:
112 """Render specs navigation."""
113 nav_items = []
114 for spec in specs:
115 is_active = spec.namespace == active_ns
117 nav_items.append(
118 el(
119 "a",
120 [
121 el("i", class_=f"fas fa-{spec.icon} w-5 mr-3 opacity-70")
122 if spec.icon
123 else "",
124 el("span", spec.label or spec.namespace.title()),
125 ],
126 href=f"?ns={spec.namespace}",
127 class_=f"flex items-center px-4 py-3 rounded-lg text-sm font-medium transition-colors {'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400' if is_active else 'text-muted-foreground hover:bg-muted dark:text-muted-foreground dark:hover:bg-card'}",
128 ),
129 )
131 if not nav_items:
132 nav_items.append(
133 el(
134 "div",
135 "No configurations found.",
136 class_="text-sm text-muted-foreground italic p-4",
137 ),
138 )
140 return el(
141 "div",
142 el(
143 "h3",
144 "Namespaces",
145 class_="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 px-2",
146 ),
147 el("nav", nav_items, class_="space-y-1"),
148 class_="w-full lg:w-64 flex-shrink-0",
149 )
151 def render_main_content(
152 self,
153 spec: dict[str, Any],
154 values: dict[str, Any],
155 namespace: str,
156 ) -> Any:
157 """Render the configuration form."""
158 nodes = spec.get("nodes", [])
160 fields = []
161 for node_data in nodes:
162 fields.append(self.render_field(node_data, values))
164 return Card(
165 title=spec.get("label", "Configuration"),
166 children=[
167 Form(
168 action_url="?ns=" + namespace,
169 method="POST",
170 hx_target="#config-card",
171 hx_swap="outerHTML",
172 children=[
173 el("input", type="hidden", name="_ns", value=namespace),
174 Stack(gap=4, children=fields),
175 el("div", class_="h-4"),
176 FormActions(submit_label="Save Changes"),
177 ],
178 ),
179 ],
180 class_="flex-1 w-full",
181 id="config-card",
182 )
184 def render_empty_state(self) -> Any:
185 """Render the empty state when no namespace is selected."""
186 return Card(
187 children=[
188 el(
189 "div",
190 el("div", "⚙️", class_="text-4xl mb-4"),
191 el(
192 "h3",
193 "Select a Namespace",
194 class_="text-lg font-medium text-foreground",
195 ),
196 el(
197 "p",
198 "Choose a configuration namespace from the sidebar to edit settings.",
199 class_="text-muted-foreground mt-2",
200 ),
201 class_="text-center py-12",
202 ),
203 ],
204 class_="flex-1 w-full",
205 )
207 def render_config_form(
208 self,
209 spec: dict[str, Any],
210 values: dict[str, Any],
211 action: str,
212 csrf_token: str | None = None,
213 ) -> Any:
214 """Render a standalone configuration form for use within ConfigLayout.
216 Args:
217 spec: Configuration spec dictionary with nodes
218 values: Current values for the spec
219 action: Form action URL
220 csrf_token: Optional CSRF token rendered as a hidden input
222 Returns:
223 Card component containing the configuration form
224 """
225 nodes = spec.get("nodes", [])
226 namespace = spec.get("namespace", "")
228 fields = []
229 for node_data in nodes:
230 fields.append(self.render_field(node_data, values))
232 hidden = []
233 if csrf_token:
234 hidden.append(
235 el("input", type="hidden", name="csrf_token", value=csrf_token)
236 )
237 hidden.append(el("input", type="hidden", name="_ns", value=namespace))
239 return Card(
240 title=spec.get("label", "Configuration"),
241 subtitle=spec.get("description", ""),
242 children=[
243 Form(
244 action_url=action,
245 method="POST",
246 hx_target="#config-card",
247 hx_swap="outerHTML",
248 children=[
249 *hidden,
250 el("div", *fields, class_="space-y-4"),
251 el("div", class_="h-4"),
252 FormActions(submit_label="Save Changes"),
253 ],
254 ),
255 ],
256 class_="w-full",
257 id="config-card",
258 )
260 def render_field(self, node: dict[str, Any], values: dict[str, Any]) -> Any:
261 """Render a single configuration field based on its type."""
262 name = node["name"]
263 value = values.get(name, node["default"])
264 label = node["label"]
265 help_text = node["help_text"]
266 node_type = node["type"]
267 readonly = node["readonly"]
268 options = node.get("options", [])
270 # Decide input component based on type
271 input_comp: Any = None
273 if node_type == "boolean":
274 input_comp = BooleanField(
275 name=name,
276 value=bool(value),
277 label=label,
278 )
279 elif node_type == "int":
280 input_comp = NumberInput(name=name, value=value, disabled=readonly)
281 elif node_type == "enum":
282 # Normalize options to list of (value, label) tuples if needed
283 choices = []
284 if isinstance(options, dict):
285 choices = list(options.items())
286 else:
287 choices = [(str(o), str(o)) for o in options]
289 input_comp = Select(
290 name=name,
291 choices=choices,
292 value=str(value) if value is not None else "",
293 disabled=readonly,
294 )
295 elif node_type == "secret":
296 has_value = bool(value)
297 presence_note = "(currently set)" if has_value else "(not set)"
298 help_text = f"{help_text} {presence_note}" if help_text else presence_note
299 input_comp = TextInput(
300 name=name,
301 value="",
302 input_type="password",
303 placeholder="••••••••" if has_value else "",
304 disabled=readonly,
305 )
306 elif node_type == "color":
307 input_comp = TextInput(
308 name=name,
309 value=str(value) if value is not None else "",
310 input_type="color",
311 disabled=readonly,
312 )
313 else: # string and others
314 input_comp = TextInput(
315 name=name,
316 value=str(value) if value is not None else "",
317 disabled=readonly,
318 )
320 return FieldSchema(
321 input_component=input_comp,
322 label=label if node_type != "boolean" else None,
323 help_text=help_text,
324 class_="mb-0",
325 )