Coverage for src / lexigram / admin / schema / relation.py: 34%
77 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +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 return BelongsTo(
36 name=self.name,
37 resource=self.resource,
38 searchable=self.searchable,
39 **kwargs,
40 ).render()
42 def from_form(self, raw: str | None) -> Result[str | None, FieldError]:
43 if raw is None:
44 return Ok(None)
45 stripped = raw.strip()
46 if not stripped:
47 if self.nullable:
48 return Ok(None)
49 return Err(FieldError("Invalid option"))
50 return Ok(stripped)
52 def render_column(self, record: Any, value: str | None) -> Element:
53 if value is None:
54 return Element("span", "\u2014", class_="text-muted")
55 return Element("span", value)
58@dataclass(frozen=True, kw_only=True)
59class BelongsToField(RelationField):
60 """A belongs-to relationship field.
62 Renders a searchable dropdown or record picker for selecting the
63 related record this record belongs to.
64 """
67@dataclass(frozen=True, kw_only=True)
68class HasManyField(RelationField):
69 """A has-many relationship field.
71 Renders a multi-select widget for picking multiple related records.
72 """
74 def render_form( # type: ignore[override]
75 self, value: list[str] | None, *, errors: list[str] | None = None
76 ) -> Element:
77 kwargs: dict[str, Any] = {
78 "name": self.name,
79 "choices": self.options,
80 }
81 if value is not None:
82 kwargs["value"] = value
83 if self.label is not None:
84 kwargs["label"] = self.label
85 if errors:
86 kwargs["error"] = errors[0]
87 if self.placeholder is not None:
88 kwargs["placeholder"] = self.placeholder
89 return MultiSelect(**kwargs).render()
91 def render_column( # type: ignore[override]
92 self, record: Any, value: list[str] | None
93 ) -> Element:
94 if value is None:
95 return Element("span", "\u2014", class_="text-muted")
96 count = len(value)
97 label = "item" if count == 1 else "items"
98 return Element("span", f"{count} {label}")
100 def from_form(self, raw: str | None) -> Result[list[str] | None, FieldError]: # type: ignore[override]
101 if raw is None:
102 return Ok(None)
103 stripped = raw.strip()
104 if not stripped:
105 if self.nullable:
106 return Ok(None)
107 return Err(FieldError("Invalid option"))
108 values = [v.strip() for v in stripped.split(",") if v.strip()]
109 return Ok(values)
111 def to_form(self, value: list[str] | None) -> str: # type: ignore[override]
112 if value is None:
113 return ""
114 return ",".join(str(v) for v in value)
117@dataclass(frozen=True, kw_only=True)
118class MorphField(RelationField):
119 """A polymorphic relation selector field.
121 Renders a type selector (which related model type) alongside a
122 record search input.
123 """
125 morph_types: list[tuple[str, str]] = field(default_factory=list)
127 def render_form(
128 self, value: str | None, *, errors: list[str] | None = None
129 ) -> Element:
130 type_select = Select(
131 name=f"{self.name}_type",
132 choices=self.morph_types,
133 value=value if value is not None else "",
134 ).render()
136 record_select = Select(
137 name=f"{self.name}_id",
138 value=value if value is not None else "",
139 ).render()
141 return Element("div", type_select, record_select, class_="morph-field")