Coverage for src/lexigram/admin/schema/composite.py: 94%

100 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass 

4from typing import Any 

5 

6from lexigram.admin.schema.base import SchemaField 

7from lexigram.admin.schema.exceptions import FieldError 

8from lexigram.result import Err, Ok, Result 

9from lexigram.serialization import dumps_str, loads_str 

10from lexigram.ui import Element, FileUpload, TextArea 

11 

12 

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

14class JsonField(SchemaField[dict | list]): 

15 """A JSON editor field for dict/list values.""" 

16 

17 def render_form( 

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

19 ) -> Element: 

20 kwargs: dict[str, Any] = {"name": self.name, "rows": 8} 

21 if value is not None: 

22 kwargs["value"] = dumps_str(value, indent=2) 

23 if self.label is not None: 

24 kwargs["label"] = self.label 

25 if errors: 

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

27 if self.readonly: 

28 kwargs["disabled"] = True 

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

30 

31 def render_column(self, record: Any, value: dict | list | None) -> Element: 

32 if value is None: 

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

34 pretty = dumps_str(value, indent=2) 

35 return Element( 

36 "pre", 

37 pretty, 

38 class_="text-xs text-muted-foreground max-w-md overflow-x-auto", 

39 ) 

40 

41 def from_form(self, raw: str | None) -> Result[dict | list | None, FieldError]: 

42 if raw is None: 

43 return Ok(None) 

44 stripped = raw.strip() 

45 if not stripped: 

46 if self.nullable: 

47 return Ok(None) 

48 return Err(FieldError("Invalid JSON")) 

49 try: 

50 parsed = loads_str(stripped) 

51 except ValueError: 

52 return Err(FieldError("Invalid JSON")) 

53 if not isinstance(parsed, (dict, list)): 

54 return Err(FieldError("Must be a JSON object or array")) 

55 return Ok(parsed) 

56 

57 def to_form(self, value: dict | list | None) -> str: 

58 if value is None: 

59 return "" 

60 return dumps_str(value) 

61 

62 

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

64class HiddenField(SchemaField[Any]): 

65 """A hidden input field for storing values not displayed to the user.""" 

66 

67 def render_form( 

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

69 ) -> Element: 

70 return Element( 

71 "input", 

72 type="hidden", 

73 name=self.name, 

74 value=value if value is not None else "", 

75 ) 

76 

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

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

79 

80 def from_form(self, raw: str | None) -> Result[Any, FieldError]: 

81 return Ok(raw) 

82 

83 def to_form(self, value: Any | None) -> str: 

84 return "" if value is None else str(value) 

85 

86 

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

88class FileField(SchemaField[str]): 

89 """A file upload field.""" 

90 

91 def render_form( 

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

93 ) -> Element: 

94 kwargs: dict[str, Any] = {"name": self.name} 

95 if self.label is not None: 

96 kwargs["label"] = self.label 

97 if self.required: 

98 kwargs["required"] = True 

99 if errors: 

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

101 if self.readonly: 

102 kwargs["disabled"] = True 

103 return FileUpload(**kwargs).render() 

104 

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

106 if value is None: 

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

108 return Element( 

109 "span", Element("a", value, href=value), class_="text-primary-600" 

110 ) 

111 

112 def from_form(self, raw: str | None) -> Result[str | None, FieldError]: 

113 if raw is None: 

114 return Ok(None) 

115 stripped = raw.strip() 

116 if not stripped: 

117 if self.nullable: 

118 return Ok(None) 

119 return Ok("") 

120 return Ok(stripped) 

121 

122 def to_form(self, value: str | None) -> str: 

123 return "" if value is None else value 

124 

125 

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

127class ImageField(FileField): 

128 """An image upload field with thumbnail preview.""" 

129 

130 thumbnail_size: int = 64 

131 

132 def render_infolist_entry(self, value: Any) -> Any: 

133 from lexigram.ui import InfolistEntry, InfolistEntryType 

134 

135 return InfolistEntry( 

136 name=self.name, 

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

138 value=value, 

139 type=InfolistEntryType.IMAGE, 

140 ) 

141 

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

143 if value is None: 

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

145 return Element( 

146 "img", 

147 src=value, 

148 style=( 

149 f"width: {self.thumbnail_size}px;" 

150 f" height: {self.thumbnail_size}px;" 

151 " object-fit: cover;" 

152 " border-radius: 4px;" 

153 ), 

154 alt="", 

155 ) 

156 

157 

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

159class AvatarField(ImageField): 

160 """An avatar upload field with circular thumbnail preview.""" 

161 

162 size: int = 40 

163 

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

165 if value is None: 

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

167 return Element( 

168 "img", 

169 src=value, 

170 style=( 

171 f"width: {self.size}px;" 

172 f" height: {self.size}px;" 

173 " border-radius: 50%;" 

174 " object-fit: cover;" 

175 ), 

176 alt="", 

177 )