Coverage for src/lexigram/admin/forms/layout.py: 0%
100 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +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_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 )
58@dataclass
59class Section(AbstractLayoutNode):
60 """A visual grouping of fields with a title and description."""
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
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 )
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 )
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
116@dataclass
117class Grid(AbstractLayoutNode):
118 """A multi-column layout container (Row)."""
120 columns: int = 2
121 children: list[Section | FieldNode | Grid | Tabs | Column] = field(
122 default_factory=list,
123 )
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)
130@dataclass
131class Column(AbstractLayoutNode):
132 """A vertical column (Col) in a Grid/Row."""
134 span: int | None = None
135 children: list[Section | FieldNode | Grid | Tabs] = field(default_factory=list)
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)
142@dataclass
143class Tab(AbstractLayoutNode):
144 """A single tab in a Tabs container."""
146 label: str = ""
147 icon: str | None = None
148 children: list[Section | FieldNode | Grid | Tabs | Column] = field(
149 default_factory=list,
150 )
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")
157@dataclass
158class Tabs(AbstractLayoutNode):
159 """A container for multiple tabs."""
161 tabs: list[Tab] = field(default_factory=list)
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 )
175class FormLayoutBuilder:
176 """Fluent API for building form layouts."""
178 def __init__(self) -> None:
179 self._children: list[Any] = []
181 @classmethod
182 def create(cls) -> FormLayoutBuilder:
183 return cls()
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
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
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
217 def build(self) -> list[AbstractLayoutNode]:
218 return self._children
221@dataclass
222class FormLayout:
223 """Schema definition for form layout."""
225 sections: list[dict[str, Any]] = field(default_factory=list)