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

100 statements  

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

43 if self.visible_when: 

44 return el( 

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

46 ) 

47 return rendered 

48 except KeyError: 

49 return el( 

50 "div", 

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

52 class_="text-destructive", 

53 ) 

54 

55 

56@dataclass 

57class Section(AbstractLayoutNode): 

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

59 

60 title: str | None = None 

61 description: str | None = None 

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

63 default_factory=list, 

64 ) 

65 columns: int = 1 

66 

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

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

69 header = [] 

70 if self.title: 

71 header.append( 

72 el( 

73 "h3", 

74 self.title, 

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

76 ), 

77 ) 

78 if self.description: 

79 header.append( 

80 el( 

81 "p", 

82 self.description, 

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

84 ), 

85 ) 

86 

87 header_el = ( 

88 el( 

89 "div", 

90 *header, 

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

92 ) 

93 if header 

94 else "" 

95 ) 

96 

97 return_el = el( 

98 "div", 

99 header_el, 

100 el( 

101 "div", 

102 *content, 

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

104 ), 

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

106 ) 

107 if self.visible_when: 

108 return el( 

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

110 ) 

111 return return_el 

112 

113 

114@dataclass 

115class Grid(AbstractLayoutNode): 

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

117 

118 columns: int = 2 

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

120 default_factory=list, 

121 ) 

122 

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

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

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

126 

127 

128@dataclass 

129class Column(AbstractLayoutNode): 

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

131 

132 span: int | None = None 

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

134 

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

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

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

138 

139 

140@dataclass 

141class Tab(AbstractLayoutNode): 

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

143 

144 label: str = "" 

145 icon: str | None = None 

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

147 default_factory=list, 

148 ) 

149 

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

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

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

153 

154 

155@dataclass 

156class Tabs(AbstractLayoutNode): 

157 """A container for multiple tabs.""" 

158 

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

160 

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

162 tab_headers = [ 

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

164 ] 

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

166 return el( 

167 "div", 

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

169 *tab_contents, 

170 ) 

171 

172 

173class FormLayoutBuilder: 

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

175 

176 def __init__(self) -> None: 

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

178 

179 @classmethod 

180 def create(cls) -> FormLayoutBuilder: 

181 return cls() 

182 

183 def section( 

184 self, 

185 title: str, 

186 fields: list[str], 

187 description: str | None = None, 

188 columns: int = 1, 

189 ) -> FormLayoutBuilder: 

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

191 self._children.append( 

192 Section( 

193 title=title, 

194 description=description, 

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

196 columns=columns, 

197 ), 

198 ) 

199 return self 

200 

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

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

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

204 return self 

205 

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

207 """Add a tabs section.""" 

208 tab_nodes = [] 

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

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

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

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

213 return self 

214 

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

216 return self._children 

217 

218 

219@dataclass 

220class FormLayout: 

221 """Schema definition for form layout.""" 

222 

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