Coverage for src/lexigram/admin/schema/belongs_to_many.py: 91%

43 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""BelongsToMany field for forms — stores related IDs as JSON array.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import Any 

7 

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 

13 

14 

15@dataclass(frozen=True, kw_only=True) 

16class BelongsToManyField(SchemaField[list[str]]): 

17 """A multi-select field for belongs-to-many relationships. 

18 

19 Renders a searchable multi-select interface and stores related 

20 record IDs as a JSON array. 

21 

22 Example: 

23 roles_field = BelongsToManyField( 

24 name="roles", 

25 label="Roles", 

26 searchable=True, 

27 ) 

28 """ 

29 

30 def render_form( 

31 self, value: list[str] | None, *, errors: list[str] | None = None 

32 ) -> Element: 

33 items = value or [] 

34 

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 ) 

48 

49 hidden = Element( 

50 "input", 

51 type="hidden", 

52 name=self.name, 

53 **{":value": "serializedIds"}, 

54 ) 

55 

56 selected_display = Element( 

57 "div", 

58 **{"x-text": "`${selectedIds.length} selected`"}, 

59 class_="text-sm text-muted-foreground mb-2", 

60 ) 

61 

62 content = Element( 

63 "div", 

64 selected_display, 

65 hidden, 

66 **{"x-data": x_data}, 

67 class_="space-y-2", 

68 ) 

69 

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 ) 

91 

92 return content 

93 

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 ) 

104 

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) 

121 

122 def to_form(self, value: list[str] | None) -> str: 

123 return "" if value is None else dumps_str(value)