Coverage for src / lexigram / admin / ui / organisms / bulk_edit_modal.py: 0%

36 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 17:07 +0800

1""" 

2Bulk Edit Slide-Over Component. 

3 

4Provides UI for bulk editing multiple records with field updates. 

5Now rendered as a slide-over panel consistent with the unified overlay system. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import Any 

11 

12from htpy import div, form, input_, label, option, p, select, span, textarea 

13 

14from lexigram.admin.actions.bulk_manager import BulkEditField 

15from lexigram.admin.ui.organisms.admin_slide_over import render_slide_over_fragment 

16from lexigram.ui import Button, el, raw 

17 

18 

19def bulk_edit_modal( 

20 selected_count: int, 

21 fields: list[BulkEditField], 

22 action_url: str, 

23 preview_items: list[str] | None = None, 

24) -> str: 

25 """ 

26 Render a bulk-edit slide-over panel. 

27 

28 Args: 

29 selected_count: Number of selected records 

30 fields: List of editable fields 

31 action_url: URL to submit the form to 

32 preview_items: Optional list of item labels for preview 

33 

34 Returns: 

35 HTML string for the SlideOver zone (``#slide-over-container``) 

36 """ 

37 preview_block: Any = "" 

38 if preview_items: 

39 preview_block = el( 

40 "div", 

41 { 

42 "class": "mb-5 rounded-xl bg-primary-50 dark:bg-primary-950/30 " 

43 "border border-primary-200 dark:border-primary-800/50 p-4", 

44 }, 

45 el( 

46 "p", 

47 { 

48 "class": "text-sm font-semibold text-primary-800 dark:text-primary-200 mb-2" 

49 }, 

50 "Selected records:", 

51 ), 

52 el( 

53 "div", 

54 {"class": "space-y-1"}, 

55 *[ 

56 el( 

57 "p", 

58 { 

59 "class": "text-sm text-primary-700 dark:text-primary-300 truncate" 

60 }, 

61 f"{item}", 

62 ) 

63 for item in preview_items[:5] 

64 ], 

65 *( 

66 [ 

67 el( 

68 "p", 

69 {"class": "text-xs text-primary-500 mt-1"}, 

70 f"…and {len(preview_items) - 5} more", 

71 ) 

72 ] 

73 if len(preview_items) > 5 

74 else [] 

75 ), 

76 ), 

77 ) 

78 

79 body = el( 

80 "div", 

81 {"class": "space-y-5"}, 

82 preview_block, 

83 raw( 

84 f'<form id="bulk-edit-form" hx-post="{action_url}" ' 

85 'hx-target="#table-body" hx-swap="outerHTML">' 

86 '<div class="space-y-4">' 

87 + "".join(_render_field_html(f) for f in fields) 

88 + "</div></form>" 

89 ), 

90 ) 

91 

92 cancel_btn = el( 

93 "button", 

94 { 

95 "type": "button", 

96 "x-on:click": "open = false", 

97 "class": ( 

98 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium " 

99 "text-foreground bg-card " 

100 "border border-border " 

101 "hover:bg-muted dark:hover:bg-muted transition-colors" 

102 ), 

103 }, 

104 "Cancel", 

105 ) 

106 submit_btn = el( 

107 "button", 

108 { 

109 "type": "submit", 

110 "form": "bulk-edit-form", 

111 "x-on:click": "open = false", 

112 "class": ( 

113 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium " 

114 "text-white bg-primary-600 hover:bg-primary-700 " 

115 "focus:outline-none focus:ring-2 focus:ring-primary-500 " 

116 "transition-colors shadow-sm" 

117 ), 

118 }, 

119 "Update Records", 

120 ) 

121 

122 return render_slide_over_fragment( 

123 title=f"Bulk Edit {selected_count} Record{'s' if selected_count != 1 else ''}", 

124 content=body, 

125 subtitle="Changes will be applied to all selected records.", 

126 footer=[cancel_btn, submit_btn], 

127 size="xl", 

128 ) 

129 

130 

131def _render_field_html(field: BulkEditField) -> str: 

132 """Render a single form field as an HTML string (used in slide-over body).""" 

133 field_id = f"bulk-edit-{field.name}" 

134 input_cls = ( 

135 "mt-1 block w-full rounded-lg border border-border " 

136 "bg-card text-foreground px-3 py-2 text-sm " 

137 "focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors" 

138 ) 

139 required_attr = "required" if field.required else "" 

140 req_star = ( 

141 '<span class="text-destructive ml-0.5">*</span>' if field.required else "" 

142 ) 

143 label_html = ( 

144 f'<label for="{field_id}" class="block text-sm font-medium ' 

145 f'text-foreground mb-1">{field.label}{req_star}</label>' 

146 ) 

147 

148 if field.field_type == "select" and field.options: 

149 options_html = '<option value="">-- No change --</option>' + "".join( 

150 f'<option value="{v}">{lbl}</option>' for v, lbl in field.options 

151 ) 

152 input_html = f'<select id="{field_id}" name="{field.name}" {required_attr} class="{input_cls}">{options_html}</select>' 

153 elif field.field_type == "textarea": 

154 input_html = f'<textarea id="{field_id}" name="{field.name}" rows="3" {required_attr} class="{input_cls}"></textarea>' 

155 elif field.field_type == "checkbox": 

156 input_html = ( 

157 f'<input type="checkbox" id="{field_id}" name="{field.name}" value="true" ' 

158 f'class="mt-1 h-4 w-4 rounded border-border text-primary-600 focus:ring-primary-500">' 

159 ) 

160 else: 

161 input_html = ( 

162 f'<input type="{field.field_type}" id="{field_id}" name="{field.name}" ' 

163 f'{required_attr} class="{input_cls}">' 

164 ) 

165 

166 help_html = ( 

167 f'<p class="mt-1 text-xs text-muted-foreground">{field.help_text}</p>' 

168 if field.help_text 

169 else "" 

170 ) 

171 return f'<div class="space-y-1">{label_html}{input_html}{help_html}</div>' 

172 

173 """Render a single form field.""" 

174 field_id = f"bulk-edit-{field.name}" 

175 

176 # Label 

177 label_elem = label( 

178 for_=field_id, 

179 class_="block text-sm font-medium text-foreground", 

180 )[ 

181 field.label, 

182 span(class_="text-destructive")[" *"] if field.required else None, 

183 ] 

184 

185 # Input element based on type 

186 if field.field_type == "select" and field.options: 

187 input_elem = select( 

188 id=field_id, 

189 name=field.name, 

190 required=field.required, 

191 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm", 

192 )[ 

193 option(value="")["-- No change --"], 

194 [option(value=str(vl[0]))[vl[1]] for vl in field.options], 

195 ] 

196 elif field.field_type == "textarea": 

197 input_elem = textarea( 

198 id=field_id, 

199 name=field.name, 

200 required=field.required, 

201 rows="3", 

202 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm", 

203 ) 

204 elif field.field_type == "checkbox": 

205 input_elem = input_( 

206 type="checkbox", 

207 id=field_id, 

208 name=field.name, 

209 value="true", 

210 class_="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-ring", 

211 ) 

212 else: 

213 input_elem = input_( 

214 type=field.field_type, 

215 id=field_id, 

216 name=field.name, 

217 required=field.required, 

218 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm", 

219 ) 

220 

221 # Help text 

222 help_elem = ( 

223 p(class_="mt-1 text-sm text-muted-foreground")[field.help_text] 

224 if field.help_text 

225 else None 

226 ) 

227 

228 return div(class_="form-field")[label_elem, input_elem, help_elem] 

229 

230 

231def bulk_assign_modal( 

232 selected_count: int, 

233 field_label: str, 

234 options: list[tuple[Any, str]], 

235 action_url: str, 

236 field_name: str = "value", 

237 allow_null: bool = False, 

238 confirm_message: str | None = None, 

239) -> Any: 

240 """ 

241 Render a modal for bulk assign operations (status, owner, etc.). 

242 

243 Args: 

244 selected_count: Number of selected records 

245 field_label: Label for the field being assigned 

246 options: List of (value, label) tuples 

247 action_url: URL to submit the form to 

248 field_name: Name attribute for the select field 

249 allow_null: Whether to show "Unassign" option 

250 confirm_message: Optional custom confirmation message 

251 

252 Returns: 

253 htpy component for the modal 

254 """ 

255 return div( 

256 class_="fixed inset-0 bg-muted bg-opacity-50 hidden", 

257 id="bulk-assign-modal", 

258 )[ 

259 div(class_="flex items-center justify-center min-h-screen px-4")[ 

260 div( 

261 class_="bg-card rounded-lg shadow-xl max-w-lg w-full", 

262 )[ 

263 # Header 

264 div(class_="px-6 py-4 border-b border-border")[ 

265 div(class_="flex items-center justify-between")[ 

266 p(class_="text-lg font-semibold text-foreground")[ 

267 f"Bulk Assign {field_label}" 

268 ], 

269 Button( 

270 type="button", 

271 color="ghost", 

272 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')", 

273 )["✕"], # type: ignore[index] 

274 ], 

275 ], 

276 # Body 

277 div(class_="px-6 py-4")[ 

278 ( 

279 div( 

280 class_="mb-4 p-3 bg-warning/10 rounded-lg", 

281 )[ 

282 p(class_="text-sm text-warning")[ 

283 confirm_message 

284 or f"This will update {selected_count} record(s)." 

285 ] 

286 ] 

287 ), 

288 form( 

289 id="bulk-assign-form", 

290 hx_post=action_url, 

291 hx_target="#table-body", 

292 hx_swap="outerHTML", 

293 )[ 

294 label( 

295 for_="bulk-assign-value", 

296 class_="block text-sm font-medium text-foreground mb-2", 

297 )[f"Select {field_label}"], 

298 select( 

299 id="bulk-assign-value", 

300 name=field_name, 

301 required=not allow_null, 

302 class_="block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring", 

303 )[ 

304 ( 

305 option(value="", selected=True)["-- Unassign --"] 

306 if allow_null 

307 else None 

308 ), 

309 [ 

310 option(value=str(val))[label_text] 

311 for val, label_text in options 

312 ], 

313 ], 

314 ], 

315 ], 

316 # Footer 

317 div( 

318 class_="px-6 py-4 border-t border-border flex justify-end space-x-3", 

319 )[ 

320 Button( 

321 type="button", 

322 color="secondary", 

323 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')", 

324 )["Cancel"], # type: ignore[index] 

325 Button( 

326 type="submit", 

327 form="bulk-assign-form", 

328 color="primary", 

329 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')", 

330 )["Assign"], # type: ignore[index] 

331 ], 

332 ] 

333 ] 

334 ] 

335 

336 

337def bulk_confirm_dialog( 

338 action_name: str, 

339 selected_count: int, 

340 preview_items: list[str] | None = None, 

341 is_danger: bool = False, 

342 action_url: str = "", 

343) -> Any: 

344 """ 

345 Render a confirmation dialog for bulk actions. 

346 

347 Args: 

348 action_name: Name of the action (e.g., "delete", "archive") 

349 selected_count: Number of selected records 

350 preview_items: Optional preview of affected items 

351 is_danger: Whether this is a dangerous action (red styling) 

352 action_url: URL to submit the action to 

353 

354 Returns: 

355 htpy component for the confirmation dialog 

356 """ 

357 

358 return div( 

359 class_="fixed inset-0 bg-muted bg-opacity-50 hidden", 

360 id="bulk-confirm-dialog", 

361 )[ 

362 div(class_="flex items-center justify-center min-h-screen px-4")[ 

363 div( 

364 class_="bg-card rounded-lg shadow-xl max-w-md w-full", 

365 )[ 

366 # Header 

367 div(class_="px-6 py-4")[ 

368 p(class_="text-lg font-semibold text-foreground")[ 

369 f"Confirm {action_name.title()}" 

370 ], 

371 p(class_="mt-2 text-sm text-muted-foreground")[ 

372 f"Are you sure you want to {action_name} {selected_count} record(s)?" 

373 ], 

374 ], 

375 # Preview 

376 ( 

377 div(class_="px-6 py-2")[ 

378 div( 

379 class_="max-h-40 overflow-y-auto border border-border rounded p-3 bg-muted dark:bg-background", 

380 )[ 

381 [ 

382 p( 

383 class_="text-sm text-foreground truncate", 

384 )[f"{item}"] 

385 for item in (preview_items or [])[:10] 

386 ], 

387 ( 

388 p( 

389 class_="text-sm text-muted-foreground mt-2", 

390 )[f"...and {len(preview_items) - 10} more"] 

391 if preview_items and len(preview_items) > 10 

392 else None 

393 ), 

394 ] 

395 ] 

396 if preview_items 

397 else None 

398 ), 

399 # Footer 

400 div( 

401 class_="px-6 py-4 border-t border-border flex justify-end space-x-3", 

402 )[ 

403 Button( 

404 type="button", 

405 color="secondary", 

406 onclick="document.getElementById('bulk-confirm-dialog').classList.add('hidden')", 

407 )["Cancel"], # type: ignore[index] 

408 Button( 

409 type="button", 

410 color="danger" if is_danger else "primary", 

411 hx_post=action_url, 

412 hx_target="#table-body", 

413 hx_swap="outerHTML", 

414 onclick="document.getElementById('bulk-confirm-dialog').classList.add('hidden')", 

415 )[action_name.title()], # type: ignore[index] 

416 ], 

417 ] 

418 ] 

419 ] 

420 

421 

422def bulk_progress_indicator( 

423 action_name: str, 

424 progress_url: str, 

425) -> Any: 

426 """ 

427 Render a progress indicator for slow bulk actions. 

428 

429 Polls the progress_url to update the progress bar. 

430 

431 Args: 

432 action_name: Name of the action being performed 

433 progress_url: URL to poll for progress updates 

434 

435 Returns: 

436 htpy component for the progress indicator 

437 """ 

438 return div( 

439 class_="fixed inset-0 bg-muted bg-opacity-50 flex items-center justify-center", 

440 id="bulk-progress", 

441 )[ 

442 div( 

443 class_="bg-card rounded-lg shadow-xl max-w-md w-full p-6", 

444 )[ 

445 p(class_="text-lg font-semibold text-foreground mb-4")[ 

446 f"{action_name.title()} in Progress..." 

447 ], 

448 # Progress bar 

449 div(class_="w-full bg-muted rounded-full h-2.5 mb-2")[ 

450 div( 

451 id="progress-bar", 

452 class_="bg-primary h-2.5 rounded-full transition-all duration-300", 

453 style="width: 0%", 

454 ) 

455 ], 

456 # Status text 

457 div( 

458 id="progress-status", 

459 class_="text-sm text-muted-foreground text-center", 

460 hx_get=progress_url, 

461 hx_trigger="every 500ms", 

462 hx_swap="innerHTML", 

463 )["Starting..."], 

464 # Errors 

465 div( 

466 id="progress-errors", 

467 class_="mt-4 text-sm text-destructive", 

468 ), 

469 ] 

470 ]