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

93 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +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 return TextArea(**kwargs).render() 

28 

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

30 if value is None: 

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

32 pretty = dumps_str(value, indent=2) 

33 return Element( 

34 "pre", 

35 pretty, 

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

37 ) 

38 

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

40 if raw is None: 

41 return Ok(None) 

42 stripped = raw.strip() 

43 if not stripped: 

44 if self.nullable: 

45 return Ok(None) 

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

47 try: 

48 parsed = loads_str(stripped) 

49 except ValueError: 

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

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

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

53 return Ok(parsed) 

54 

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

56 if value is None: 

57 return "" 

58 return dumps_str(value) 

59 

60 

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

62class HiddenField(SchemaField[Any]): 

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

64 

65 def render_form( 

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

67 ) -> Element: 

68 return Element( 

69 "input", 

70 type="hidden", 

71 name=self.name, 

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

73 ) 

74 

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

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

77 

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

79 return Ok(raw) 

80 

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

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

83 

84 

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

86class FileField(SchemaField[str]): 

87 """A file upload field.""" 

88 

89 def render_form( 

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

91 ) -> Element: 

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

93 if self.label is not None: 

94 kwargs["label"] = self.label 

95 if self.required: 

96 kwargs["required"] = True 

97 if errors: 

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

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

100 

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

102 if value is None: 

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

104 return Element( 

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

106 ) 

107 

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

109 if raw is None: 

110 return Ok(None) 

111 stripped = raw.strip() 

112 if not stripped: 

113 if self.nullable: 

114 return Ok(None) 

115 return Ok("") 

116 return Ok(stripped) 

117 

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

119 return "" if value is None else value 

120 

121 

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

123class ImageField(FileField): 

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

125 

126 thumbnail_size: int = 64 

127 

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

129 if value is None: 

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

131 return Element( 

132 "img", 

133 src=value, 

134 style=( 

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

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

137 " object-fit: cover;" 

138 " border-radius: 4px;" 

139 ), 

140 alt="", 

141 ) 

142 

143 

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

145class AvatarField(ImageField): 

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

147 

148 size: int = 40 

149 

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

151 if value is None: 

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

153 return Element( 

154 "img", 

155 src=value, 

156 style=( 

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

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

159 " border-radius: 50%;" 

160 " object-fit: cover;" 

161 ), 

162 alt="", 

163 )