Coverage for src / lexigram / admin / forms / fields / _advanced.py: 34%
65 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1from __future__ import annotations
3import copy
4from typing import Any
6from lexigram.admin.forms.fields._base import Field
7import lexigram.serialization as json
8from lexigram.ui import ColorPicker, Component, MarkdownEditor, RichEditor, TagsInput
9from lexigram.ui import KeyValueField as KeyValueWidget
12class RichTextField(Field):
13 """WYSIWYG rich-text editor field (backed by Trix)."""
15 def render(self, **kwargs) -> Component:
16 return RichEditor(
17 name=self.name,
18 value=self.value,
19 label=self.label,
20 error=str(self.errors[0]) if self.errors else None,
21 required=self.required,
22 disabled=self.disabled,
23 **kwargs,
24 )
27class MarkdownField(Field):
28 """Markdown editor field with preview."""
30 def render(self, **kwargs) -> Component:
31 return MarkdownEditor(
32 name=self.name,
33 value=self.value,
34 label=self.label,
35 error=str(self.errors[0]) if self.errors else None,
36 required=self.required,
37 disabled=self.disabled,
38 **kwargs,
39 )
42class ColorField(Field):
43 """HTML color-picker field."""
45 def render(self, **kwargs) -> Component:
46 return ColorPicker(
47 name=self.name,
48 value=self.value,
49 label=self.label,
50 error=str(self.errors[0]) if self.errors else None,
51 required=self.required,
52 disabled=self.disabled,
53 **kwargs,
54 )
57class TagsField(Field):
58 """Comma-separated tags / chips field."""
60 def render(self, **kwargs) -> Component:
61 return TagsInput(
62 name=self.name,
63 value=self.value,
64 label=self.label,
65 error=str(self.errors[0]) if self.errors else None,
66 required=self.required,
67 disabled=self.disabled,
68 **kwargs,
69 )
72class KeyValueField(Field):
73 """Dynamic key-value pairs editor (serialises to JSON object)."""
75 def render(self, **kwargs) -> Component:
76 return KeyValueWidget(
77 name=self.name,
78 value=self.value,
79 label=self.label,
80 error=str(self.errors[0]) if self.errors else None,
81 required=self.required,
82 disabled=self.disabled,
83 **kwargs,
84 )
87class JsonField(Field):
88 """JSON document editor with code-formatted textarea.
90 Renders a ``<textarea>`` pre-populated with pretty-printed JSON.
91 On submit the value is the raw JSON string; the form layer should
92 call :meth:`parse_value` to deserialise.
94 Args:
95 rows: Number of visible textarea rows (default ``10``).
96 indent: JSON pretty-print indent level (default ``2``).
97 """
99 def __init__(
100 self,
101 label: str = "",
102 *,
103 rows: int = 10,
104 indent: int = 2,
105 **kwargs: Any,
106 ) -> None:
107 super().__init__(label=label, **kwargs)
108 self.rows = rows
109 self.indent = indent
111 def bind(self, value: Any) -> JsonField:
112 """Bind a Python object (dict/list) or raw JSON string as the value."""
113 new_field = copy.copy(self)
114 if isinstance(value, str):
115 try:
116 parsed = json.loads(value)
117 dumped = json.dumps(parsed)
118 new_field.value = (
119 dumped.decode() if isinstance(dumped, bytes) else dumped
120 )
121 except (ValueError, TypeError):
122 new_field.value = value
123 elif value is not None:
124 dumped = json.dumps(value)
125 new_field.value = dumped.decode() if isinstance(dumped, bytes) else dumped
126 else:
127 new_field.value = ""
128 new_field.is_bound = True
129 return new_field
131 def parse_value(self, raw: str) -> Any:
132 """Deserialise the submitted JSON string.
134 Returns:
135 Parsed Python object.
137 Raises:
138 ValueError: If *raw* is not valid JSON.
139 """
140 try:
141 return json.loads(raw)
142 except ValueError as exc:
143 raise ValueError(f"Invalid JSON: {exc}") from exc
145 def render(self, **kwargs) -> Component:
146 from lexigram.ui.core.base import el
148 textarea_attrs: dict[str, Any] = {
149 "name": self.name,
150 "id": self.name,
151 "rows": str(self.rows),
152 "class": (
153 "block w-full rounded-md border border-border "
154 "bg-card text-foreground "
155 "font-mono text-sm p-3 "
156 "focus:outline-none focus:ring-2 focus:ring-ring "
157 "disabled:opacity-50"
158 ),
159 "spellcheck": "false",
160 }
161 if self.disabled:
162 textarea_attrs["disabled"] = True
163 if self.required:
164 textarea_attrs["required"] = True
166 raw = self.value if self.value is not None else ""
167 textarea = el("textarea", raw, **textarea_attrs)
169 label_el = el(
170 "label",
171 self.label or self.name,
172 **{
173 "for": self.name,
174 "class": "block text-sm font-medium text-foreground mb-1",
175 },
176 )
177 error_el = (
178 el("p", str(self.errors[0]), class_="mt-1 text-sm text-destructive")
179 if self.errors
180 else None
181 )
182 help_el = (
183 el("p", self.help_text, class_="mt-1 text-xs text-muted-foreground")
184 if self.help_text
185 else None
186 )
187 children = [label_el, textarea]
188 if error_el:
189 children.append(error_el)
190 if help_el:
191 children.append(help_el)
192 return el("div", *children, class_="space-y-1", **kwargs)