1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DialogVariant {
14 #[default]
15 Default,
16 Danger,
17}
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "UPPERCASE")]
22pub enum HttpMethod {
23 Get,
24 #[default]
25 Post,
26 Put,
27 Patch,
28 Delete,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct ConfirmDialog {
34 pub title: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub message: Option<String>,
37 #[serde(default)]
38 pub variant: DialogVariant,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum NotifyVariant {
45 #[default]
46 Success,
47 Info,
48 Warning,
49 Error,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum ActionOutcome {
56 Redirect {
57 url: String,
58 },
59 ShowErrors,
60 Refresh,
61 Notify {
62 message: String,
63 variant: NotifyVariant,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Action {
70 pub handler: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub url: Option<String>,
75 #[serde(default)]
76 pub method: HttpMethod,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub confirm: Option<ConfirmDialog>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub on_success: Option<ActionOutcome>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub on_error: Option<ActionOutcome>,
83}
84
85impl Action {
86 pub fn new(handler: impl Into<String>) -> Self {
88 Self {
89 handler: handler.into(),
90 url: None,
91 method: HttpMethod::Post,
92 confirm: None,
93 on_success: None,
94 on_error: None,
95 }
96 }
97
98 pub fn get(handler: impl Into<String>) -> Self {
100 Self {
101 method: HttpMethod::Get,
102 ..Self::new(handler)
103 }
104 }
105
106 pub fn delete(handler: impl Into<String>) -> Self {
108 Self {
109 method: HttpMethod::Delete,
110 ..Self::new(handler)
111 }
112 }
113
114 pub fn method(mut self, method: HttpMethod) -> Self {
116 self.method = method;
117 self
118 }
119
120 pub fn confirm(mut self, title: impl Into<String>) -> Self {
122 self.confirm = Some(ConfirmDialog {
123 title: title.into(),
124 message: None,
125 variant: DialogVariant::Default,
126 });
127 self
128 }
129
130 pub fn confirm_danger(mut self, title: impl Into<String>) -> Self {
132 self.confirm = Some(ConfirmDialog {
133 title: title.into(),
134 message: None,
135 variant: DialogVariant::Danger,
136 });
137 self
138 }
139
140 pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
142 self.on_success = Some(outcome);
143 self
144 }
145
146 pub fn on_error(mut self, outcome: ActionOutcome) -> Self {
148 self.on_error = Some(outcome);
149 self
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn minimal_action_serializes() {
159 let action = Action {
160 handler: "users.store".to_string(),
161 url: None,
162 method: HttpMethod::Post,
163 confirm: None,
164 on_success: None,
165 on_error: None,
166 };
167 let json = serde_json::to_value(&action).unwrap();
168 assert_eq!(json["handler"], "users.store");
169 assert_eq!(json["method"], "POST");
170 assert!(json.get("confirm").is_none());
171 assert!(json.get("on_success").is_none());
172 }
173
174 #[test]
175 fn action_with_confirm_dialog() {
176 let action = Action {
177 handler: "users.destroy".to_string(),
178 url: None,
179 method: HttpMethod::Delete,
180 confirm: Some(ConfirmDialog {
181 title: "Delete user?".to_string(),
182 message: Some("This cannot be undone.".to_string()),
183 variant: DialogVariant::Danger,
184 }),
185 on_success: Some(ActionOutcome::Redirect {
186 url: "/users".to_string(),
187 }),
188 on_error: Some(ActionOutcome::ShowErrors),
189 };
190 let json = serde_json::to_string(&action).unwrap();
191 let parsed: Action = serde_json::from_str(&json).unwrap();
192 assert_eq!(parsed, action);
193 }
194
195 #[test]
196 fn action_outcome_variants_serialize() {
197 let redirect = ActionOutcome::Redirect {
198 url: "/dashboard".to_string(),
199 };
200 let json = serde_json::to_value(&redirect).unwrap();
201 assert_eq!(json["type"], "redirect");
202 assert_eq!(json["url"], "/dashboard");
203
204 let show_errors = ActionOutcome::ShowErrors;
205 let json = serde_json::to_value(&show_errors).unwrap();
206 assert_eq!(json["type"], "show_errors");
207
208 let refresh = ActionOutcome::Refresh;
209 let json = serde_json::to_value(&refresh).unwrap();
210 assert_eq!(json["type"], "refresh");
211
212 let notify = ActionOutcome::Notify {
213 message: "Saved!".to_string(),
214 variant: NotifyVariant::Success,
215 };
216 let json = serde_json::to_value(¬ify).unwrap();
217 assert_eq!(json["type"], "notify");
218 assert_eq!(json["message"], "Saved!");
219 assert_eq!(json["variant"], "success");
220 }
221
222 #[test]
223 fn http_method_defaults_to_post() {
224 let json = r#"{"handler": "posts.store"}"#;
225 let action: Action = serde_json::from_str(json).unwrap();
226 assert_eq!(action.method, HttpMethod::Post);
227 }
228
229 #[test]
230 fn dialog_variant_defaults_to_default() {
231 let json = r#"{"title": "Confirm?"}"#;
232 let dialog: ConfirmDialog = serde_json::from_str(json).unwrap();
233 assert_eq!(dialog.variant, DialogVariant::Default);
234 }
235
236 #[test]
237 fn action_without_url_omits_url_field() {
238 let action = Action {
239 handler: "users.index".to_string(),
240 url: None,
241 method: HttpMethod::Get,
242 confirm: None,
243 on_success: None,
244 on_error: None,
245 };
246 let json = serde_json::to_value(&action).unwrap();
247 assert!(json.get("url").is_none(), "url should be omitted when None");
248 }
249
250 #[test]
251 fn action_with_url_includes_url_field() {
252 let action = Action {
253 handler: "users.store".to_string(),
254 url: Some("/users".to_string()),
255 method: HttpMethod::Post,
256 confirm: None,
257 on_success: None,
258 on_error: None,
259 };
260 let json = serde_json::to_value(&action).unwrap();
261 assert_eq!(json["url"], "/users");
262 }
263
264 #[test]
265 fn action_url_round_trips() {
266 let action = Action {
267 handler: "users.show".to_string(),
268 url: Some("/users/42".to_string()),
269 method: HttpMethod::Get,
270 confirm: None,
271 on_success: None,
272 on_error: None,
273 };
274 let json = serde_json::to_string(&action).unwrap();
275 let parsed: Action = serde_json::from_str(&json).unwrap();
276 assert_eq!(parsed.url, Some("/users/42".to_string()));
277 assert_eq!(parsed, action);
278 }
279
280 #[test]
283 fn builder_new_creates_post_action() {
284 let action = Action::new("users.store");
285 assert_eq!(action.handler, "users.store");
286 assert_eq!(action.method, HttpMethod::Post);
287 assert_eq!(action.url, None);
288 assert_eq!(action.confirm, None);
289 assert_eq!(action.on_success, None);
290 assert_eq!(action.on_error, None);
291 }
292
293 #[test]
294 fn builder_get_creates_get_action() {
295 let action = Action::get("users.index");
296 assert_eq!(action.handler, "users.index");
297 assert_eq!(action.method, HttpMethod::Get);
298 }
299
300 #[test]
301 fn builder_delete_creates_delete_action() {
302 let action = Action::delete("users.destroy");
303 assert_eq!(action.handler, "users.destroy");
304 assert_eq!(action.method, HttpMethod::Delete);
305 }
306
307 #[test]
308 fn builder_confirm_adds_default_dialog() {
309 let action = Action::new("users.store").confirm("Save changes?");
310 let dialog = action.confirm.unwrap();
311 assert_eq!(dialog.title, "Save changes?");
312 assert_eq!(dialog.variant, DialogVariant::Default);
313 assert_eq!(dialog.message, None);
314 }
315
316 #[test]
317 fn builder_confirm_danger_adds_danger_dialog() {
318 let action = Action::delete("users.destroy").confirm_danger("Delete user?");
319 let dialog = action.confirm.unwrap();
320 assert_eq!(dialog.title, "Delete user?");
321 assert_eq!(dialog.variant, DialogVariant::Danger);
322 }
323
324 #[test]
325 fn builder_on_success_sets_outcome() {
326 let action = Action::new("users.store").on_success(ActionOutcome::Refresh);
327 assert_eq!(action.on_success, Some(ActionOutcome::Refresh));
328 }
329
330 #[test]
331 fn builder_on_error_sets_outcome() {
332 let action = Action::new("users.store").on_error(ActionOutcome::ShowErrors);
333 assert_eq!(action.on_error, Some(ActionOutcome::ShowErrors));
334 }
335
336 #[test]
337 fn builder_chain_produces_expected_json() {
338 let action = Action::delete("users.destroy")
339 .confirm_danger("Delete user?")
340 .on_success(ActionOutcome::Refresh);
341
342 let json = serde_json::to_value(&action).unwrap();
343 assert_eq!(json["handler"], "users.destroy");
344 assert_eq!(json["method"], "DELETE");
345 assert_eq!(json["confirm"]["title"], "Delete user?");
346 assert_eq!(json["confirm"]["variant"], "danger");
347 assert_eq!(json["on_success"]["type"], "refresh");
348 }
349
350 #[test]
351 fn builder_method_overrides() {
352 let action = Action::new("users.update").method(HttpMethod::Put);
353 assert_eq!(action.method, HttpMethod::Put);
354 }
355}