Coverage for src / lexigram / ui / molecules / builder.py: 31%
36 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram import serialization as json
6from lexigram.logging import get_logger
7from lexigram.serialization import dumps_str
8from lexigram.ui.core.base import Component, NoContext, el
10logger = get_logger(__name__)
13class Builder(Component):
14 """
15 A block-based content editor.
16 Allows adding, removing, and reordering structured content blocks.
18 Data is stored as a JSON array of objects:
19 [
20 {"type": "heading", "data": {"text": "Hello"}},
21 {"type": "text", "data": {"body": "World"}}
22 ]
23 """
25 def __init__(
26 self,
27 blocks: list[Any], # List of Block objects from fields.py
28 name: str,
29 value: list[dict] | str | None = None,
30 label: str | None = None,
31 **props,
32 ) -> None:
33 super().__init__(blocks=blocks, name=name, label=label, **props)
34 self.blocks = blocks
35 self.name = name
36 self.label = label
38 # Handle JSON strings or dicts
39 if isinstance(value, str) and value:
40 from lexigram.serialization import loads_str
42 try:
43 self.value = loads_str(value)
44 except (ValueError, TypeError, json.JSONDecodeError):
45 self.value = []
46 else:
47 self.value = value or []
49 def render(self) -> Any:
50 # Pre-render block definitions to pass them to Alpine
51 block_defs = []
52 for b in self.blocks:
53 # We need to render the fields for each block template
54 # This is tricky because we want to render them such that Alpine can use them as templates
55 block_defs.append(
56 {"type": b.name, "label": b.label, "icon": b.icon or "cube"},
57 )
59 # Alpine state:
60 # items: [{id: 1, type: 'heading', data: {...}, collapsed: false}]
61 # nextId: integer
63 # Convert existing values to items with unique IDs for Alpine
64 initial_items = []
65 for i, item in enumerate(self.value):
66 initial_items.append(
67 {
68 "id": i + 1,
69 "type": item.get("type"),
70 "data": item.get("data", {}),
71 "collapsed": False,
72 },
73 )
75 x_data = {
76 "items": initial_items,
77 "nextId": len(initial_items) + 1,
78 "addBlock(type)": "this.items.push({id: this.nextId++, type: type, data: {}, collapsed: false})",
79 "removeBlock(id)": "this.items = this.items.filter(i => i.id !== id)",
80 "moveBlock(id, direction)": """
81 let idx = this.items.findIndex(i => i.id === id);
82 if (direction === 'up' && idx > 0) {
83 [this.items[idx-1], this.items[idx]] = [this.items[idx], this.items[idx-1]];
84 } else if (direction === 'down' && idx < this.items.length - 1) {
85 [this.items[idx+1], this.items[idx]] = [this.items[idx], this.items[idx+1]];
86 }
87 """,
88 }
90 # Main container
91 return el(
92 "div",
93 # Hidden input for the final JSON data
94 el(
95 "input",
96 type="hidden",
97 name=self.name,
98 # Use x_bind to keep the hidden input in sync with Alpine state
99 # We need to map the items back to the data format [ {type, data} ]
100 x_bind_value="JSON.stringify(items.map(i => ({type: i.type, data: i.data})))",
101 ),
102 # List of blocks
103 el(
104 "div",
105 # Template for blocks
106 el(
107 "template",
108 el(
109 "div",
110 # Block Header
111 el(
112 "div",
113 el(
114 "div",
115 # Drag handle / Icon
116 el(
117 "span",
118 el(
119 "i",
120 class_="fas fa-grip-vertical text-muted-foreground mr-2",
121 ),
122 ),
123 el(
124 "span",
125 x_text="items.find(it => it.id == item.id)?.type.toUpperCase()",
126 class_="font-bold text-xs tracking-wider text-muted-foreground",
127 ),
128 class_="flex items-center",
129 ),
130 el(
131 "div",
132 # Actions: Move Up, Move Down, Collapse, Delete
133 el(
134 "button",
135 el("i", class_="fas fa-chevron-up"),
136 type="button",
137 x_on_click="moveBlock(item.id, 'up')",
138 class_="p-1 hover:bg-accent rounded",
139 ),
140 el(
141 "button",
142 el("i", class_="fas fa-chevron-down"),
143 type="button",
144 x_on_click="moveBlock(item.id, 'down')",
145 class_="p-1 hover:bg-accent rounded",
146 ),
147 el(
148 "button",
149 el(
150 "i",
151 class_="fas fa-trash text-destructive",
152 ),
153 type="button",
154 x_on_click="removeBlock(item.id)",
155 class_="p-1 hover:bg-accent rounded ml-2",
156 ),
157 class_="flex items-center gap-1",
158 ),
159 class_="bg-muted border-b border-border px-4 py-2 flex items-center justify-between",
160 ),
161 # Block Body (Fields)
162 el(
163 "div",
164 # Render fields for each block type
165 *[self._render_block_fields(b) for b in self.blocks],
166 class_="p-4",
167 ),
168 class_="border border-border rounded-lg overflow-hidden bg-card mb-4",
169 x_show="true", # Alpine handles the loop
170 ),
171 x_for="item in items",
172 key="item.id",
173 ),
174 class_="builder-items",
175 ),
176 # Add block buttons
177 el(
178 "div",
179 el(
180 "div",
181 *[
182 el(
183 "button",
184 el("i", class_=f"fas fa-{b.icon or 'cube'} mr-2"),
185 b.label,
186 type="button",
187 x_on_click=f"addBlock('{b.name}')",
188 class_="inline-flex items-center px-4 py-2 border border-dashed border-border rounded-md shadow-sm text-sm font-medium text-foreground bg-card hover:bg-accent",
189 )
190 for b in self.blocks
191 ],
192 class_="flex flex-wrap gap-2",
193 ),
194 class_="mt-6 border-2 border-dashed border-border p-4 rounded-xl flex flex-col items-center justify-center",
195 ),
196 x_data=dumps_str(x_data),
197 class_="lex-builder w-full",
198 )
200 def _render_block_fields(self, block: Any) -> Any:
201 """Render the fields for a specific block type, wrapped in an Alpine conditional."""
202 with NoContext():
203 field_els = []
204 for field in block.fields:
205 # We need to bind the field's value to item.data[field_name]
206 # Lexigram components use self.name and self.props['value']
207 # We'll inject Alpine x-model into the component's props
208 field.props["x-model"] = f"item.data['{field.name}']"
209 field_els.append(field.render())
211 return el(
212 "div",
213 *field_els,
214 x_show=f"item.type === '{block.name}'",
215 class_="space-y-4",
216 )