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

1"""UI components for configuration dashboard.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

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) 

21 

22__all__ = ["ConfigDashboardUI"] 

23 

24 

25class BooleanField(Component): 

26 """Toggle with a hidden false input so unchecked states persist.""" 

27 

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 

38 

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 ) 

52 

53 

54class ConfigDashboardUI: 

55 """UI helper for configuration dashboard.""" 

56 

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 ) 

81 

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 } 

89 

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 ) 

105 

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 

116 

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 ) 

130 

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 ) 

139 

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 ) 

150 

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", []) 

159 

160 fields = [] 

161 for node_data in nodes: 

162 fields.append(self.render_field(node_data, values)) 

163 

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 ) 

183 

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 ) 

206 

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. 

215 

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 

221 

222 Returns: 

223 Card component containing the configuration form 

224 """ 

225 nodes = spec.get("nodes", []) 

226 namespace = spec.get("namespace", "") 

227 

228 fields = [] 

229 for node_data in nodes: 

230 fields.append(self.render_field(node_data, values)) 

231 

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)) 

238 

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 ) 

259 

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", []) 

269 

270 # Decide input component based on type 

271 input_comp: Any = None 

272 

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] 

288 

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 ) 

319 

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 )