Coverage for src/lexigram/admin/ui/molecules/toast_notification.py: 100%

54 statements  

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

1"""Fluent toast notification builder (Filament ``Notification::make()`` parity). 

2 

3Builds a ``ToastData`` for ``ServerToastChannel`` through a chainable API, and 

4can either render the toast HTML directly or flash it to the admin session for 

5the next request. This is intentionally distinct from the lifecycle-email 

6``Notification`` model in ``lexigram.admin.services.notifications`` — it only 

7concerns the ephemeral admin toast surface. 

8""" 

9 

10from __future__ import annotations 

11 

12from typing import Self 

13 

14from lexigram.admin.state.context import flash 

15from lexigram.ui import ServerToastChannel, ToastData, ToastType 

16 

17_TOAST_CATEGORY = { 

18 ToastType.SUCCESS: "success", 

19 ToastType.ERROR: "error", 

20 ToastType.WARNING: "warning", 

21 ToastType.INFO: "info", 

22} 

23 

24 

25class ToastNotification: 

26 """Chainable toast builder targeting ``ServerToastChannel``. 

27 

28 Args: 

29 message: Default toast message. 

30 

31 Example: 

32 ```python 

33 ToastNotification.make("Resource updated") 

34 .success() 

35 .title("Saved") 

36 .duration(3000) 

37 .send() 

38 

39 html = ToastNotification.make("Export queued") 

40 .info() 

41 .persistent() 

42 .actions([{"label": "View", "onclick": "openReport()"}]) 

43 .render() 

44 ``` 

45 """ 

46 

47 def __init__(self, message: str = "") -> None: 

48 """Initialize the builder with an empty toast.""" 

49 self._data = ToastData(message=message) 

50 

51 @classmethod 

52 def make(cls, message: str = "") -> Self: 

53 """Start a new toast notification. 

54 

55 Args: 

56 message: Initial toast message. 

57 

58 Returns: 

59 A new builder instance. 

60 """ 

61 return cls(message) 

62 

63 def title(self, title: str) -> Self: 

64 """Set the toast heading. 

65 

66 Args: 

67 title: Heading text. 

68 

69 Returns: 

70 The builder for chaining. 

71 """ 

72 self._data.title = title 

73 return self 

74 

75 def message(self, message: str) -> Self: 

76 """Set the toast body text. 

77 

78 Args: 

79 message: Body text. 

80 

81 Returns: 

82 The builder for chaining. 

83 """ 

84 self._data.message = message 

85 return self 

86 

87 def icon(self, icon: str | None) -> Self: 

88 """Override the Lucide icon name for this toast. 

89 

90 Args: 

91 icon: Lucide icon name, or ``None`` to use the type default. 

92 

93 Returns: 

94 The builder for chaining. 

95 """ 

96 self._data.icon = icon 

97 return self 

98 

99 def success(self) -> Self: 

100 """Mark the toast as a success notification. 

101 

102 Returns: 

103 The builder for chaining. 

104 """ 

105 self._data.type = ToastType.SUCCESS 

106 return self 

107 

108 def error(self) -> Self: 

109 """Mark the toast as an error notification. 

110 

111 Returns: 

112 The builder for chaining. 

113 """ 

114 self._data.type = ToastType.ERROR 

115 return self 

116 

117 def warning(self) -> Self: 

118 """Mark the toast as a warning notification. 

119 

120 Returns: 

121 The builder for chaining. 

122 """ 

123 self._data.type = ToastType.WARNING 

124 return self 

125 

126 def info(self) -> Self: 

127 """Mark the toast as an informational notification. 

128 

129 Returns: 

130 The builder for chaining. 

131 """ 

132 self._data.type = ToastType.INFO 

133 return self 

134 

135 def duration(self, duration_ms: int) -> Self: 

136 """Set the auto-dismiss delay. 

137 

138 Args: 

139 duration_ms: Delay in milliseconds. A non-positive value disables 

140 auto-dismiss. 

141 

142 Returns: 

143 The builder for chaining. 

144 """ 

145 self._data.duration_ms = max(duration_ms, 0) 

146 self._data.auto_dismiss = self._data.duration_ms > 0 

147 return self 

148 

149 def persistent(self) -> Self: 

150 """Keep the toast until manually dismissed. 

151 

152 Returns: 

153 The builder for chaining. 

154 """ 

155 self._data.auto_dismiss = False 

156 self._data.duration_ms = 0 

157 return self 

158 

159 def dismissible(self, value: bool = True) -> Self: 

160 """Set whether the toast shows a dismiss button. 

161 

162 Args: 

163 value: Whether the toast can be dismissed by the user. 

164 

165 Returns: 

166 The builder for chaining. 

167 """ 

168 self._data.dismissible = value 

169 return self 

170 

171 def actions(self, actions: list[dict[str, str]]) -> Self: 

172 """Attach action buttons to the toast. 

173 

174 Args: 

175 actions: List of ``{"label": ..., "onclick": ...}`` entries. 

176 

177 Returns: 

178 The builder for chaining. 

179 """ 

180 self._data.actions = list(actions) 

181 return self 

182 

183 def to_toast(self) -> ToastData: 

184 """Return the configured ``ToastData`` payload. 

185 

186 Returns: 

187 The toast data this builder has configured so far. 

188 """ 

189 return self._data 

190 

191 def render(self) -> str: 

192 """Render the toast as standalone HTML. 

193 

194 Returns: 

195 HTML for a single toast element (works inside the toast container). 

196 """ 

197 return ServerToastChannel().render_toast(self._data) 

198 

199 def send(self) -> None: 

200 """Flash the toast to the admin session for the next request. 

201 

202 Category is derived from the toast type; title/icon/duration are not 

203 carried by the flash channel, so use :meth:`render` for full fidelity. 

204 Outside a request context this is a no-op. 

205 

206 Returns: 

207 None. 

208 """ 

209 category = _TOAST_CATEGORY.get(ToastType(self._data.type), "info") 

210 flash(self._data.title or self._data.message, category) 

211 

212 

213__all__ = ["ToastNotification"]