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

1from __future__ import annotations 

2 

3import copy 

4from typing import Any 

5 

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 

10 

11 

12class RichTextField(Field): 

13 """WYSIWYG rich-text editor field (backed by Trix).""" 

14 

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 ) 

25 

26 

27class MarkdownField(Field): 

28 """Markdown editor field with preview.""" 

29 

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 ) 

40 

41 

42class ColorField(Field): 

43 """HTML color-picker field.""" 

44 

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 ) 

55 

56 

57class TagsField(Field): 

58 """Comma-separated tags / chips field.""" 

59 

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 ) 

70 

71 

72class KeyValueField(Field): 

73 """Dynamic key-value pairs editor (serialises to JSON object).""" 

74 

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 ) 

85 

86 

87class JsonField(Field): 

88 """JSON document editor with code-formatted textarea. 

89 

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. 

93 

94 Args: 

95 rows: Number of visible textarea rows (default ``10``). 

96 indent: JSON pretty-print indent level (default ``2``). 

97 """ 

98 

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 

110 

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 

130 

131 def parse_value(self, raw: str) -> Any: 

132 """Deserialise the submitted JSON string. 

133 

134 Returns: 

135 Parsed Python object. 

136 

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 

144 

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

146 from lexigram.ui.core.base import el 

147 

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 

165 

166 raw = self.value if self.value is not None else "" 

167 textarea = el("textarea", raw, **textarea_attrs) 

168 

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)