Coverage for src/lexigram/admin/schema/belongs_to_many.py: 42%
43 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1"""BelongsToMany field for forms — stores related IDs as JSON array."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import Any
8from lexigram.admin.schema.base import SchemaField
9from lexigram.admin.schema.exceptions import FieldError
10from lexigram.result import Err, Ok, Result
11from lexigram.serialization import dumps_str, loads_str
12from lexigram.ui import Element
15@dataclass(frozen=True, kw_only=True)
16class BelongsToManyField(SchemaField[list[str]]):
17 """A multi-select field for belongs-to-many relationships.
19 Renders a searchable multi-select interface and stores related
20 record IDs as a JSON array.
22 Example:
23 roles_field = BelongsToManyField(
24 name="roles",
25 label="Roles",
26 searchable=True,
27 )
28 """
30 def render_form(
31 self, value: list[str] | None, *, errors: list[str] | None = None
32 ) -> Element:
33 items = value or []
35 x_data = (
36 f"{{"
37 f" selectedIds: {dumps_str(items)},"
38 f" searchQuery: '',"
39 f" isOpen: false,"
40 f" toggleId(id) {{"
41 f" let idx = this.selectedIds.indexOf(id);"
42 f" if(idx === -1) {{ this.selectedIds.push(id); }}"
43 f" else {{ this.selectedIds.splice(idx, 1); }}"
44 f" }},"
45 f" get serializedIds() {{ return JSON.stringify(this.selectedIds); }},"
46 f"}}"
47 )
49 hidden = Element(
50 "input",
51 type="hidden",
52 name=self.name,
53 **{":value": "serializedIds"},
54 )
56 selected_display = Element(
57 "div",
58 **{"x-text": "`${selectedIds.length} selected`"},
59 class_="text-sm text-muted-foreground mb-2",
60 )
62 content = Element(
63 "div",
64 selected_display,
65 hidden,
66 **{"x-data": x_data},
67 class_="space-y-2",
68 )
70 if self.label:
71 return Element(
72 "div",
73 Element(
74 "label",
75 self.label,
76 class_="block text-sm font-medium text-foreground mb-1",
77 ),
78 content,
79 (
80 Element(
81 "p",
82 errors[0],
83 id=f"{self.name}-error",
84 class_="mt-2 text-sm text-destructive",
85 )
86 if errors
87 else ""
88 ),
89 class_="mb-6",
90 )
92 return content
94 def render_column(self, record: Any, value: list[str] | None) -> Element:
95 if value is None or not value:
96 return Element("span", "\u2014", class_="text-muted")
97 count = len(value)
98 label = "relation" if count == 1 else "relations"
99 return Element(
100 "span",
101 f"{count} {label}",
102 class_="text-sm text-muted-foreground",
103 )
105 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]:
106 if raw is None:
107 return Ok(None)
108 stripped = raw.strip()
109 if not stripped:
110 if self.nullable:
111 return Ok(None)
112 return Err(FieldError("Invalid JSON array"))
113 try:
114 parsed = loads_str(stripped)
115 except ValueError:
116 return Err(FieldError("Invalid JSON array"))
117 if not isinstance(parsed, list):
118 return Err(FieldError("Must be a JSON array"))
119 validated = [str(x) for x in parsed if x is not None or str(x).strip()]
120 return Ok(validated)
122 def to_form(self, value: list[str] | None) -> str:
123 return "" if value is None else dumps_str(value)