Coverage for src/lexigram/admin/schema/text_area.py: 32%

85 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4import html as _html 

5from typing import Any 

6 

7from lexigram.admin.schema.text import TextField 

8from lexigram.security.sanitization.html import sanitize_html 

9from lexigram.ui import Element, MarkdownEditor, RichEditor, TextArea, raw 

10 

11 

12def _sanitize_rich_text(value: str) -> str: 

13 """Sanitize rich-text HTML, escaping verbatim when nh3 is unavailable.""" 

14 try: 

15 return sanitize_html(value) 

16 except ImportError: 

17 return _html.escape(value) 

18 

19 

20@dataclass(frozen=True, kw_only=True) 

21class TextAreaField(TextField): 

22 """A multi-line text input field. 

23 

24 Renders a ``<textarea>`` form control. Column display truncates 

25 long content at 200 characters. 

26 """ 

27 

28 rows: int = 5 

29 

30 def render_form( 

31 self, value: str | None, *, errors: list[str] | None = None 

32 ) -> Element: 

33 kwargs: dict[str, Any] = { 

34 "name": self.name, 

35 "value": value if value is not None else "", 

36 "rows": self.rows, 

37 } 

38 if self.placeholder is not None: 

39 kwargs["placeholder"] = self.placeholder 

40 if self.label is not None: 

41 kwargs["label"] = self.label 

42 if errors: 

43 kwargs["error"] = errors[0] 

44 if self.readonly: 

45 kwargs["disabled"] = True 

46 return TextArea(**kwargs).render() 

47 

48 def render_column(self, record: Any, value: str | None) -> Element: 

49 if value is None: 

50 return Element("span", "\u2014", class_="text-muted") 

51 if len(value) > 200: 

52 return Element("span", value[:200] + "...") 

53 return Element("span", value) 

54 

55 

56@dataclass(frozen=True, kw_only=True) 

57class MarkdownField(TextAreaField): 

58 """A markdown editor field with optional preview toggle. 

59 

60 Renders a ``MarkdownEditor`` form control with Alpine.js-powered 

61 preview mode switching. Column display strips markdown syntax 

62 for plain-text preview. 

63 """ 

64 

65 preview: bool = True 

66 min_height: int = 300 

67 

68 def render_form( 

69 self, value: str | None, *, errors: list[str] | None = None 

70 ) -> Element: 

71 kwargs: dict[str, Any] = { 

72 "name": self.name, 

73 "value": value if value is not None else "", 

74 "rows": self.rows, 

75 "preview": self.preview, 

76 "min_height": self.min_height, 

77 } 

78 if self.placeholder is not None: 

79 kwargs["placeholder"] = self.placeholder 

80 if self.label is not None: 

81 kwargs["label"] = self.label 

82 if errors: 

83 kwargs["error"] = errors[0] 

84 if self.readonly: 

85 kwargs["disabled"] = True 

86 return MarkdownEditor(**kwargs).render() 

87 

88 def render_column(self, record: Any, value: str | None) -> Element: 

89 if value is None: 

90 return Element("span", "\u2014", class_="text-muted") 

91 text = value 

92 text = text.replace("**", "").replace("__", "") 

93 text = text.replace("*", "").replace("_", "") 

94 text = text.replace("```", "").replace("`", "") 

95 text = text.replace("#", "") 

96 text = text.replace("> ", "") 

97 text = text.replace("[", "").replace("]", "") 

98 text = text.replace("(", "").replace(")", "") 

99 return Element("span", text.strip()) 

100 

101 

102@dataclass(frozen=True, kw_only=True) 

103class RichTextField(TextAreaField): 

104 """A WYSIWYG rich-text field. 

105 

106 Renders a ``RichEditor`` form control backed by Trix.js. 

107 Column display renders the HTML value as trusted (unescaped) 

108 content. 

109 

110 Use ``render_assets()`` to include the required Trix.js CSS and 

111 JS when this field appears on a page. 

112 """ 

113 

114 toolbar: str | None = None 

115 min_height: int = 300 

116 

117 _trix_css_url: str = "https://unpkg.com/trix@2.0.8/dist/trix.css" 

118 _trix_js_url: str = "https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js" 

119 

120 @classmethod 

121 def render_assets(cls) -> Element: 

122 """Render the Trix.js CSS and ``<script>`` tags. 

123 

124 Include the result in ``<head>`` once per page to enable 

125 RichEditor instances. 

126 """ 

127 return Element( 

128 "div", 

129 Element( 

130 "link", 

131 rel="stylesheet", 

132 href=cls._trix_css_url, 

133 ), 

134 Element( 

135 "script", 

136 src=cls._trix_js_url, 

137 defer="", 

138 ), 

139 ) 

140 

141 def render_form( 

142 self, value: str | None, *, errors: list[str] | None = None 

143 ) -> Element: 

144 kwargs: dict[str, Any] = { 

145 "name": self.name, 

146 "value": value if value is not None else "", 

147 "min_height": self.min_height, 

148 } 

149 if self.placeholder is not None: 

150 kwargs["placeholder"] = self.placeholder 

151 if self.label is not None: 

152 kwargs["label"] = self.label 

153 if self.toolbar is not None: 

154 kwargs["toolbar"] = self.toolbar 

155 if errors: 

156 kwargs["error"] = errors[0] 

157 if self.readonly: 

158 kwargs["disabled"] = True 

159 return RichEditor(**kwargs).render() 

160 

161 def render_column(self, record: Any, value: str | None) -> Element: 

162 if value is None: 

163 return Element("span", "\u2014", class_="text-muted") 

164 return Element("div", raw(_sanitize_rich_text(value)))