Coverage for src/lexigram/admin/forms/layout.py: 84%

100 statements  

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

1"""Unified Form Layout System. 

2Combines schema definitions with rendering logic. 

3""" 

4 

5from __future__ import annotations 

6 

7from abc import ABC, abstractmethod 

8from dataclasses import dataclass, field 

9from typing import TYPE_CHECKING, Any 

10 

11from lexigram.ui import Col as AtomCol 

12from lexigram.ui import Row as AtomRow 

13from lexigram.ui import el 

14 

15if TYPE_CHECKING: 

16 from lexigram.admin.forms.components import FormBase 

17 

18 

19@dataclass 

20class AbstractLayoutNode(ABC): 

21 """Base class for all layout elements.""" 

22 

23 id: str | None = None 

24 metadata: dict[str, Any] = field(default_factory=dict) 

25 visible_when: str | None = None 

26 

27 @abstractmethod 

28 def render(self, form: FormBase) -> Any: 

29 """Render the node given a bound form instance.""" 

30 

31 

32@dataclass 

33class FieldNode(AbstractLayoutNode): 

34 """Represents a single field in the layout.""" 

35 

36 field_name: str = "" 

37 props: dict[str, Any] = field(default_factory=dict) 

38 

39 def render(self, form: FormBase) -> Any: 

40 try: 

41 field = form.fields[self.field_name] 

42 rendered = field.render_form( 

43 self.props.get("value"), errors=self.props.get("errors") 

44 ) 

45 if self.visible_when: 

46 return el( 

47 "div", rendered, **{"x-show": self.visible_when, "x-cloak": True} 

48 ) 

49 return rendered 

50 except KeyError: 

51 return el( 

52 "div", 

53 f"Error: Field '{self.field_name}' not found", 

54 class_="text-destructive", 

55 ) 

56 

57 

58@dataclass 

59class Section(AbstractLayoutNode): 

60 """A visual grouping of fields with a title and description.""" 

61 

62 title: str | None = None 

63 description: str | None = None 

64 children: list[Section | FieldNode | Grid | Tabs | Column] = field( 

65 default_factory=list, 

66 ) 

67 columns: int = 1 

68 

69 def render(self, form: FormBase) -> Any: 

70 content = [child.render(form) for child in self.children] 

71 header = [] 

72 if self.title: 

73 header.append( 

74 el( 

75 "h3", 

76 self.title, 

77 class_="text-lg font-medium text-foreground", 

78 ), 

79 ) 

80 if self.description: 

81 header.append( 

82 el( 

83 "p", 

84 self.description, 

85 class_="mt-1 text-sm text-muted-foreground", 

86 ), 

87 ) 

88 

89 header_el = ( 

90 el( 

91 "div", 

92 *header, 

93 class_="mb-4 pb-2 border-b border-border", 

94 ) 

95 if header 

96 else "" 

97 ) 

98 

99 return_el = el( 

100 "div", 

101 header_el, 

102 el( 

103 "div", 

104 *content, 

105 class_=f"grid grid-cols-1 md:grid-cols-{self.columns} gap-4", 

106 ), 

107 class_="bg-muted dark:bg-card/50 p-4 rounded-lg border border-border", 

108 ) 

109 if self.visible_when: 

110 return el( 

111 "div", return_el, **{"x-show": self.visible_when, "x-cloak": True} 

112 ) 

113 return return_el 

114 

115 

116@dataclass 

117class Grid(AbstractLayoutNode): 

118 """A multi-column layout container (Row).""" 

119 

120 columns: int = 2 

121 children: list[Section | FieldNode | Grid | Tabs | Column] = field( 

122 default_factory=list, 

123 ) 

124 

125 def render(self, form: FormBase) -> Any: 

126 rendered_children = [child.render(form) for child in self.children] 

127 return AtomRow(*rendered_children, cols=self.columns, gap=4) 

128 

129 

130@dataclass 

131class Column(AbstractLayoutNode): 

132 """A vertical column (Col) in a Grid/Row.""" 

133 

134 span: int | None = None 

135 children: list[Section | FieldNode | Grid | Tabs] = field(default_factory=list) 

136 

137 def render(self, form: FormBase) -> Any: 

138 rendered_children = [child.render(form) for child in self.children] 

139 return AtomCol(*rendered_children, span=self.span, gap=4) 

140 

141 

142@dataclass 

143class Tab(AbstractLayoutNode): 

144 """A single tab in a Tabs container.""" 

145 

146 label: str = "" 

147 icon: str | None = None 

148 children: list[Section | FieldNode | Grid | Tabs | Column] = field( 

149 default_factory=list, 

150 ) 

151 

152 def render(self, form: FormBase) -> Any: 

153 content = [child.render(form) for child in self.children] 

154 return el("div", *content, class_="space-y-4") 

155 

156 

157@dataclass 

158class Tabs(AbstractLayoutNode): 

159 """A container for multiple tabs.""" 

160 

161 tabs: list[Tab] = field(default_factory=list) 

162 

163 def render(self, form: FormBase) -> Any: 

164 tab_headers = [ 

165 el("button", tab.label, class_="px-4 py-2 border-b-2") for tab in self.tabs 

166 ] 

167 tab_contents = [tab.render(form) for tab in self.tabs] 

168 return el( 

169 "div", 

170 el("div", *tab_headers, class_="flex space-x-4 border-b"), 

171 *tab_contents, 

172 ) 

173 

174 

175class FormLayoutBuilder: 

176 """Fluent API for building form layouts.""" 

177 

178 def __init__(self) -> None: 

179 self._children: list[Any] = [] 

180 

181 @classmethod 

182 def create(cls) -> FormLayoutBuilder: 

183 return cls() 

184 

185 def section( 

186 self, 

187 title: str, 

188 fields: list[str], 

189 description: str | None = None, 

190 columns: int = 1, 

191 ) -> FormLayoutBuilder: 

192 child_nodes = [FieldNode(field_name=f) for f in fields] 

193 self._children.append( 

194 Section( 

195 title=title, 

196 description=description, 

197 children=child_nodes, # type: ignore[arg-type] 

198 columns=columns, 

199 ), 

200 ) 

201 return self 

202 

203 def grid(self, columns: int, children: list[Any]) -> FormLayoutBuilder: 

204 nodes = [FieldNode(field_name=c) if isinstance(c, str) else c for c in children] 

205 self._children.append(Grid(columns=columns, children=nodes)) 

206 return self 

207 

208 def tabs(self, tabs_config: dict[str, list[str]]) -> FormLayoutBuilder: 

209 """Add a tabs section.""" 

210 tab_nodes = [] 

211 for label, fields in tabs_config.items(): 

212 children = [FieldNode(field_name=f) for f in fields] 

213 tab_nodes.append(Tab(label=label, children=children)) # type: ignore[arg-type] 

214 self._children.append(Tabs(tabs=tab_nodes)) 

215 return self 

216 

217 def build(self) -> list[AbstractLayoutNode]: 

218 return self._children 

219 

220 

221@dataclass 

222class FormLayout: 

223 """Schema definition for form layout.""" 

224 

225 sections: list[dict[str, Any]] = field(default_factory=list)