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

1from __future__ import annotations 

2 

3from typing import Any 

4 

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 

9 

10logger = get_logger(__name__) 

11 

12 

13class Builder(Component): 

14 """ 

15 A block-based content editor. 

16 Allows adding, removing, and reordering structured content blocks. 

17 

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 """ 

24 

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 

37 

38 # Handle JSON strings or dicts 

39 if isinstance(value, str) and value: 

40 from lexigram.serialization import loads_str 

41 

42 try: 

43 self.value = loads_str(value) 

44 except (ValueError, TypeError, json.JSONDecodeError): 

45 self.value = [] 

46 else: 

47 self.value = value or [] 

48 

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 ) 

58 

59 # Alpine state: 

60 # items: [{id: 1, type: 'heading', data: {...}, collapsed: false}] 

61 # nextId: integer 

62 

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 ) 

74 

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 } 

89 

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 ) 

199 

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()) 

210 

211 return el( 

212 "div", 

213 *field_els, 

214 x_show=f"item.type === '{block.name}'", 

215 class_="space-y-4", 

216 )