Skip to main content

ferro_json_ui/
action.rs

1//! Action declarations for JSON-UI components.
2//!
3//! Actions map user interactions (button clicks, form submissions) to
4//! backend Ferro handlers. Each action references a handler in
5//! `"controller.method"` format and can include confirmation dialogs
6//! and outcome behaviors.
7
8use serde::{Deserialize, Serialize};
9
10/// Variant for confirmation dialogs.
11#[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/// HTTP method for action requests.
20#[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/// Confirmation dialog shown before executing an action.
32#[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/// Notification variant for action outcomes.
42#[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/// Outcome after an action completes (success or error).
53#[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/// An action declaration mapping a user interaction to a backend handler.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Action {
70    /// Handler reference in "controller.method" format.
71    pub handler: String,
72    /// Resolved URL for this action. Populated by the resolver at render time.
73    #[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    /// Create an action with Post method (the default for form submissions).
87    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    /// Create a navigation action with Get method.
99    pub fn get(handler: impl Into<String>) -> Self {
100        Self {
101            method: HttpMethod::Get,
102            ..Self::new(handler)
103        }
104    }
105
106    /// Create a deletion action with Delete method.
107    pub fn delete(handler: impl Into<String>) -> Self {
108        Self {
109            method: HttpMethod::Delete,
110            ..Self::new(handler)
111        }
112    }
113
114    /// Override the HTTP method.
115    pub fn method(mut self, method: HttpMethod) -> Self {
116        self.method = method;
117        self
118    }
119
120    /// Add a default confirmation dialog.
121    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    /// Add a danger confirmation dialog.
131    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    /// Set the success outcome.
141    pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
142        self.on_success = Some(outcome);
143        self
144    }
145
146    /// Set the error outcome.
147    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(&notify).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    // ── Builder method tests ──────────────────────────────────────────
281
282    #[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}