Coverage for src/lexigram/admin/schema/relation.py: 38%
81 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
1from __future__ import annotations
3from dataclasses import dataclass, field
4from typing import Any
6from lexigram.admin.schema.exceptions import FieldError
7from lexigram.admin.schema.select import SelectField
8from lexigram.result import Err, Ok, Result
9from lexigram.ui import BelongsTo, Element, MultiSelect, Select
12@dataclass(frozen=True, kw_only=True)
13class RelationField(SelectField):
14 """Base field for related-record selection.
16 Renders a record picker widget (BelongsTo) for selecting a related
17 database record.
18 """
20 resource: str
21 searchable: bool = False
23 def render_form(
24 self, value: str | None, *, errors: list[str] | None = None
25 ) -> Element:
26 kwargs: dict[str, Any] = {}
27 if value is not None:
28 kwargs["value"] = value
29 if self.label is not None:
30 kwargs["label"] = self.label
31 if errors:
32 kwargs["error"] = errors[0]
33 if self.options:
34 kwargs["choices"] = self.options
35 if self.readonly:
36 kwargs["disabled"] = True
37 return BelongsTo(
38 name=self.name,
39 resource=self.resource,
40 searchable=self.searchable,
41 **kwargs,
42 ).render()
44 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
45 if raw is None:
46 return Ok(None)
47 stripped = raw.strip()
48 if not stripped:
49 if self.nullable:
50 return Ok(None)
51 return Err(FieldError("Invalid option"))
52 return Ok(stripped)
54 def render_column(self, record: Any, value: str | None) -> Element:
55 if value is None:
56 return Element("span", "\u2014", class_="text-muted")
57 return Element("span", value)
60@dataclass(frozen=True, kw_only=True)
61class BelongsToField(RelationField):
62 """A belongs-to relationship field.
64 Renders a searchable dropdown or record picker for selecting the
65 related record this record belongs to.
66 """
69@dataclass(frozen=True, kw_only=True)
70class HasManyField(RelationField):
71 """A has-many relationship field.
73 Renders a multi-select widget for picking multiple related records.
74 """
76 def render_form( # type: ignore[override]
77 self, value: list[str] | None, *, errors: list[str] | None = None
78 ) -> Element:
79 kwargs: dict[str, Any] = {
80 "name": self.name,
81 "choices": self.options,
82 }
83 if value is not None:
84 kwargs["value"] = value
85 if self.label is not None:
86 kwargs["label"] = self.label
87 if errors:
88 kwargs["error"] = errors[0]
89 if self.placeholder is not None:
90 kwargs["placeholder"] = self.placeholder
91 if self.readonly:
92 kwargs["disabled"] = True
93 return MultiSelect(**kwargs).render()
95 def render_column( # type: ignore[override]
96 self, record: Any, value: list[str] | None
97 ) -> Element:
98 if value is None:
99 return Element("span", "\u2014", class_="text-muted")
100 count = len(value)
101 label = "item" if count == 1 else "items"
102 return Element("span", f"{count} {label}")
104 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]: # type: ignore[override]
105 if raw is None:
106 return Ok(None)
107 stripped = raw.strip()
108 if not stripped:
109 if self.nullable:
110 return Ok(None)
111 return Err(FieldError("Invalid option"))
112 values = [v.strip() for v in stripped.split(",") if v.strip()]
113 return Ok(values)
115 def to_form(self, value: list[str] | None) -> str: # type: ignore[override]
116 if value is None:
117 return ""
118 return ",".join(str(v) for v in value)
121@dataclass(frozen=True, kw_only=True)
122class MorphField(RelationField):
123 """A polymorphic relation selector field.
125 Renders a type selector (which related model type) alongside a
126 record search input.
127 """
129 morph_types: list[tuple[str, str]] = field(default_factory=list)
131 def render_form(
132 self, value: str | None, *, errors: list[str] | None = None
133 ) -> Element:
134 type_select = Select(
135 name=f"{self.name}_type",
136 choices=self.morph_types,
137 value=value if value is not None else "",
138 disabled=self.readonly,
139 ).render()
141 record_select = Select(
142 name=f"{self.name}_id",
143 value=value if value is not None else "",
144 disabled=self.readonly,
145 ).render()
147 return Element("div", type_select, record_select, class_="morph-field")