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
« 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"""
5from __future__ import annotations
7from abc import ABC, abstractmethod
8from dataclasses import dataclass, field
9from typing import TYPE_CHECKING, Any
11from lexigram.ui import Col as AtomCol
12from lexigram.ui import Row as AtomRow
13from lexigram.ui import el
15if TYPE_CHECKING:
16 from lexigram.admin.forms.components import FormBase
19@dataclass
20class AbstractLayoutNode(ABC):
21 """Base class for all layout elements."""
23 id: str | None = None
24 metadata: dict[str, Any] = field(default_factory=dict)
25 visible_when: str | None = None
27 @abstractmethod
28 def render(self, form: FormBase) -> Any:
29 """Render the node given a bound form instance."""
32@dataclass
33class FieldNode(AbstractLayoutNode):
34 """Represents a single field in the layout."""
36 field_name: str = ""
37 props: dict[str, Any] = field(default_factory=dict)
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 )
56@dataclass
57class Section(AbstractLayoutNode):
58 """A visual grouping of fields with a title and description."""
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
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 )
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 )
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
114@dataclass
115class Grid(AbstractLayoutNode):
116 """A multi-column layout container (Row)."""
118 columns: int = 2
119 children: list[Section | FieldNode | Grid | Tabs | Column] = field(
120 default_factory=list,
121 )
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)
128@dataclass
129class Column(AbstractLayoutNode):
130 """A vertical column (Col) in a Grid/Row."""
132 span: int | None = None
133 children: list[Section | FieldNode | Grid | Tabs] = field(default_factory=list)
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)
140@dataclass
141class Tab(AbstractLayoutNode):
142 """A single tab in a Tabs container."""
144 label: str = ""
145 icon: str | None = None
146 children: list[Section | FieldNode | Grid | Tabs | Column] = field(
147 default_factory=list,
148 )
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")
155@dataclass
156class Tabs(AbstractLayoutNode):
157 """A container for multiple tabs."""
159 tabs: list[Tab] = field(default_factory=list)
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 )
173class FormLayoutBuilder:
174 """Fluent API for building form layouts."""
176 def __init__(self) -> None:
177 self._children: list[Any] = []
179 @classmethod
180 def create(cls) -> FormLayoutBuilder:
181 return cls()
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
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
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
215 def build(self) -> list[AbstractLayoutNode]:
216 return self._children
219@dataclass
220class FormLayout:
221 """Schema definition for form layout."""
223 sections: list[dict[str, Any]] = field(default_factory=list)