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