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 schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Variant for confirmation dialogs.
12#[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/// HTTP method for action requests.
21#[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/// Confirmation dialog shown before executing an action.
33#[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/// Notification variant for action outcomes.
43#[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/// Outcome after an action completes (success or error).
54#[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/// An action declaration mapping a user interaction to a backend handler.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70pub struct Action {
71    /// Handler reference in "controller.method" format.
72    pub handler: String,
73    /// Resolved URL for this action. Populated by the resolver at render time.
74    #[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    /// Anchor target for navigation (GET) actions — e.g. "_blank" to open
85    /// in a new tab. When set, `rel="noopener noreferrer"` is added.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub target: Option<String>,
88}
89
90impl Action {
91    /// Create an action with Post method (the default for form submissions).
92    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    /// Create a navigation action with Get method.
105    pub fn get(handler: impl Into<String>) -> Self {
106        Self {
107            method: HttpMethod::Get,
108            ..Self::new(handler)
109        }
110    }
111
112    /// Create a deletion action with Delete method.
113    pub fn delete(handler: impl Into<String>) -> Self {
114        Self {
115            method: HttpMethod::Delete,
116            ..Self::new(handler)
117        }
118    }
119
120    /// Override the HTTP method.
121    pub fn method(mut self, method: HttpMethod) -> Self {
122        self.method = method;
123        self
124    }
125
126    /// Add a default confirmation dialog.
127    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    /// Add a danger confirmation dialog.
137    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    /// Set the success outcome.
147    pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
148        self.on_success = Some(outcome);
149        self
150    }
151
152    /// Set the error outcome.
153    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(&notify).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    // ── Builder method tests ──────────────────────────────────────────
292
293    #[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}