Coverage for src / lexigram / admin / forms / fields / _base.py: 38%

254 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1from __future__ import annotations 

2 

3from abc import ABC, abstractmethod 

4import copy 

5from dataclasses import dataclass, field 

6from enum import StrEnum 

7from typing import TYPE_CHECKING, Any, Self 

8 

9from lexigram.logging import get_logger 

10 

11if TYPE_CHECKING: 

12 from collections.abc import Callable 

13 

14 from lexigram.ui.core.base import Component 

15 

16logger = get_logger(__name__) 

17 

18 

19class FieldType(StrEnum): 

20 """Supported form field types.""" 

21 

22 TEXT = "text" 

23 NUMBER = "number" 

24 EMAIL = "email" 

25 PASSWORD = "password" 

26 CHECKBOX = "checkbox" 

27 SELECT = "select" 

28 MULTI_SELECT = "multi_select" 

29 DATE = "date" 

30 DATETIME = "datetime" 

31 TEXTAREA = "textarea" 

32 FILE = "file" 

33 IMAGE = "image" 

34 NESTED = "nested" 

35 LIST = "list" 

36 RICH_TEXT = "rich_text" 

37 MARKDOWN = "markdown" 

38 COLOR = "color" 

39 TAGS = "tags" 

40 KEY_VALUE = "key_value" 

41 JSON = "json" 

42 BELONGS_TO = "belongs_to" 

43 HAS_MANY = "has_many" 

44 MORPH = "morph" 

45 

46 

47@dataclass 

48class FieldSchema: 

49 """Definition of a single form field schema.""" 

50 

51 name: str 

52 label: str 

53 type: FieldType = FieldType.TEXT 

54 required: bool = False 

55 default: Any = None 

56 placeholder: str | None = None 

57 help_text: str | None = None 

58 options: list[dict[str, Any]] = field(default_factory=list) 

59 metadata: dict[str, Any] = field(default_factory=dict) 

60 nested_schema: Any | None = None # Avoid circular with FormSchema 

61 validation: dict[str, Any] = field(default_factory=dict) 

62 visible: bool = True 

63 editable: bool = True 

64 masked: bool = False 

65 related_resource: str | None = None 

66 """Name of the related admin resource (e.g. 'users', 'pets').""" 

67 related_field: str | None = None 

68 """The FK field name on the related side (for HAS_MANY).""" 

69 

70 

71class Block: 

72 """Definition of a content block for the Builder component.""" 

73 

74 def __init__( 

75 self, 

76 name: str, 

77 fields: list[Any], 

78 label: str | None = None, 

79 icon: str | None = None, 

80 ): 

81 self.name = name 

82 self.fields = fields 

83 self.label = label or name.replace("_", " ").title() 

84 self.icon = icon 

85 

86 

87class AdminField: 

88 """Metadata for a Pydantic field to control how it's rendered in the admin UI.""" 

89 

90 def __init__( 

91 self, 

92 label: str | None = None, 

93 widget: type | None = None, 

94 help_text: str | None = None, 

95 belongs_to: str | None = None, 

96 belongs_to_many: str | None = None, 

97 searchable: bool = False, 

98 email: bool = False, 

99 url: bool = False, 

100 min_value: int | None = None, 

101 max_value: int | None = None, 

102 regex: str | None = None, 

103 unique: bool = False, 

104 confirmed: bool = False, 

105 **props: Any, 

106 ): 

107 self._label = label 

108 self._widget = widget 

109 self._help_text = help_text 

110 self._belongs_to = belongs_to 

111 self._belongs_to_many = belongs_to_many 

112 self._searchable = searchable 

113 self.email = email 

114 self.url = url 

115 self.min = min_value 

116 self.max = max_value 

117 self.regex = regex 

118 self._unique = unique 

119 self.confirmed = confirmed 

120 self._placeholder: str | None = None 

121 self._default_value: Any = None 

122 self._disabled = False 

123 self._readonly = False 

124 self._hidden = False 

125 self._visible_condition: Callable[[dict], bool] | None = None 

126 self._hint: str | None = None 

127 self._props = props 

128 

129 def label(self, value: str) -> Self: 

130 self._label = value 

131 return self 

132 

133 def widget(self, value: type) -> Self: 

134 self._widget = value 

135 return self 

136 

137 def props(self, **kwargs: Any) -> Self: 

138 self._props.update(kwargs) 

139 return self 

140 

141 def help_text(self, value: str) -> Self: 

142 self._help_text = value 

143 return self 

144 

145 def placeholder(self, value: str) -> Self: 

146 self._placeholder = value 

147 return self 

148 

149 def default(self, value: Any) -> Self: 

150 self._default_value = value 

151 return self 

152 

153 def disabled(self, value: bool = True) -> Self: 

154 self._disabled = value 

155 return self 

156 

157 def readonly(self, value: bool = True) -> Self: 

158 self._readonly = value 

159 return self 

160 

161 def hidden(self, value: bool = True) -> Self: 

162 self._hidden = value 

163 return self 

164 

165 def visible(self, condition: Callable[[dict], bool]) -> Self: 

166 self._visible_condition = condition 

167 return self 

168 

169 def visible_when(self, expression: str) -> Self: 

170 self._props["visible_when"] = expression 

171 return self 

172 

173 def hint(self, text: str) -> Self: 

174 self._hint = text 

175 return self 

176 

177 def searchable(self, value: bool = True) -> Self: 

178 self._searchable = value 

179 return self 

180 

181 def builder(self, blocks: list[Block]) -> Self: 

182 self._props["builder_blocks"] = blocks 

183 self._widget = "Builder" # type: ignore[assignment] 

184 return self 

185 

186 def prefix(self, text: str) -> Self: 

187 self._props["prefix"] = text 

188 return self 

189 

190 def suffix(self, text: str) -> Self: 

191 self._props["suffix"] = text 

192 return self 

193 

194 def mask(self, pattern: str) -> Self: 

195 self._props["mask"] = pattern 

196 return self 

197 

198 def currency(self, code: str = "USD") -> Self: 

199 self._props["currency"] = code 

200 return self 

201 

202 def format_state_using(self, callback: Callable[[Any], Any]) -> Self: 

203 self._props["format_state_using"] = callback 

204 return self 

205 

206 def dehydrate_state_using(self, callback: Callable[[Any], Any]) -> Self: 

207 self._props["dehydrate_state_using"] = callback 

208 return self 

209 

210 def prefill_from( 

211 self, 

212 field_name: str, 

213 transformer_js: str | None = None, 

214 ) -> Self: 

215 self._props["prefill_from"] = field_name 

216 self._props["prefill_transformer"] = transformer_js 

217 return self 

218 

219 def __call__(self, label: str) -> Self: 

220 self._label = label 

221 return self 

222 

223 

224class AbstractField(ABC): 

225 """Abstract base class for all form fields.""" 

226 

227 def __init__( 

228 self, 

229 label: str | None = None, 

230 required: bool = True, 

231 disabled: bool = False, 

232 help_text: str | None = None, 

233 default: Any = None, 

234 placeholder: str | None = None, 

235 validators: list[Callable] | None = None, 

236 name: str | None = None, 

237 realtime_validate: bool = False, 

238 async_validate: Callable[[Any], Any] | None = None, 

239 format_state_using: Callable[[Any], Any] | None = None, 

240 dehydrate_state_using: Callable[[Any], Any] | None = None, 

241 autocomplete_source: str | Callable[[], list[str]] | None = None, 

242 mask_pattern: str | None = None, 

243 prefix: str | None = None, 

244 suffix: str | None = None, 

245 currency: str | None = None, 

246 decimals: int | None = None, 

247 prefill_from: str | None = None, 

248 ): 

249 self.label = label 

250 self.required = required 

251 self.disabled = disabled 

252 self.help_text = help_text 

253 self.default = default 

254 self.placeholder = placeholder 

255 self.validators = validators or [] 

256 self._name = name 

257 self.realtime_validate = realtime_validate 

258 self.async_validator = async_validate 

259 self.format_state_using = format_state_using 

260 self.dehydrate_state_using = dehydrate_state_using 

261 self.autocomplete_source = autocomplete_source 

262 self.mask_pattern = mask_pattern 

263 self.prefix = prefix 

264 self.suffix = suffix 

265 self._currency = currency 

266 self.decimals = decimals 

267 self.prefill_from = prefill_from 

268 self.aria_label = label 

269 self.aria_describedby = f"{self.name}-help" if help_text else None 

270 self.aria_errormessage = f"{self.name}-error" 

271 self.value: Any = default 

272 self.errors: list[str] = [] 

273 self.is_bound = False 

274 self.is_validating = False 

275 

276 def get_default(self) -> Any: 

277 """Return the default value for this field.""" 

278 return self.default 

279 

280 @property 

281 def name(self) -> str: 

282 return self._name or "unnamed_field" 

283 

284 @name.setter 

285 def name(self, value: str) -> Any: 

286 self._name = value 

287 

288 def bind(self, value: Any) -> AbstractField: 

289 new_field = copy.copy(self) 

290 new_field.value = value if value is not None else self.default 

291 new_field.is_bound = True 

292 return new_field 

293 

294 def validate(self, value: Any) -> Any: 

295 if self.required and value in (None, ""): 

296 raise ValueError(f"{self.label or self.name} is required") 

297 return value 

298 

299 async def run_async_validation(self, value: Any) -> Any: 

300 if self.async_validator: 

301 try: 

302 self.is_validating = True 

303 result = await self.async_validator(value) 

304 self.is_validating = False 

305 return result 

306 except Exception as e: # noqa: BLE001 — async validators are user-supplied callables that may raise anything 

307 self.is_validating = False 

308 logger.exception("Async validator failed for field %s", self._name) 

309 raise ValueError(str(e)) from None 

310 return value 

311 

312 def format_value(self, value: Any) -> Any: 

313 if self.format_state_using: 

314 try: 

315 return self.format_state_using(value) 

316 except BaseException: 

317 logger.exception("Custom formatter failed for field %s", self._name) 

318 return value 

319 

320 def dehydrate_value(self, value: Any) -> Any: 

321 if self.dehydrate_state_using: 

322 try: 

323 return self.dehydrate_state_using(value) 

324 except BaseException: 

325 logger.exception("Custom dehydrator failed for field %s", self._name) 

326 return value 

327 

328 def apply_mask(self, value: str) -> str: 

329 if not self.mask_pattern or not value: 

330 return value 

331 masked = "" 

332 value_idx = 0 

333 for char in self.mask_pattern: 

334 if char == "#" and value_idx < len(value): 

335 if value[value_idx].isdigit(): 

336 masked += value[value_idx] 

337 value_idx += 1 

338 else: 

339 break 

340 elif char != "#" and value_idx < len(value): 

341 masked += char 

342 if value[value_idx] == char: 

343 value_idx += 1 

344 elif char != "#": 

345 masked += char 

346 return masked 

347 

348 @abstractmethod 

349 def render(self, **kwargs) -> Component: 

350 pass 

351 

352 def render_with_conditional(self, **kwargs) -> Any: 

353 """Render the field, wrapped in an Alpine x-show div when ``visible_when`` is set. 

354 

355 This is the preferred call site when rendering fields inside a 

356 ``FormBase`` or ``LayoutNode``, as it transparently adds the 

357 ``x-show`` / ``x-cloak`` attributes required for declarative 

358 show/hide based on other field values. 

359 """ 

360 from lexigram.ui.core.base import el as _el 

361 

362 rendered = self.render(**kwargs) 

363 expr = getattr(self, "_visible_expression", None) 

364 if expr: 

365 return _el("div", rendered, **{"x-show": expr, "x-cloak": True}) 

366 return rendered 

367 

368 def visible_when(self, expression: str) -> AbstractField: 

369 """Declare a client-side Alpine.js condition controlling visibility. 

370 

371 Args: 

372 expression: An Alpine.js boolean expression evaluated in the form 

373 context, e.g. ``"formData.type === 'premium'"``. 

374 

375 Returns: 

376 Self for method chaining. 

377 """ 

378 self._visible_expression = expression 

379 return self 

380 

381 def is_visible(self, form_data: dict[str, Any]) -> bool: 

382 """Server-side visibility check (Python-evaluated). 

383 

384 For client-side (Alpine.js) show/hide use :meth:`visible_when`. 

385 """ 

386 return True 

387 

388 

389# Backward-compatible public symbol expected by field modules/importers. 

390Field = AbstractField