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//!
8//! Handler resolution accepts two shapes (Phase 165 — F14):
9//!
10//! - **Literal:** a plain string — `"users.store"` or `"/users"`.
11//! - **Binding:** a `{"$data": "/path"}` map resolving to a string in
12//!   `spec.data` at render time. Inside `$each` templates, paths that
13//!   start with the loop variable (`/{as}/...`) are inlined to the row
14//!   value during expansion — see `resolve.rs::expand_each`.
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::spec::DataRef;
20
21/// Variant for confirmation dialogs.
22#[derive(
23    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
24)]
25#[serde(rename_all = "snake_case")]
26#[strum(serialize_all = "snake_case")]
27pub enum DialogVariant {
28    #[default]
29    Default,
30    Danger,
31}
32
33/// HTTP method for action requests.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "UPPERCASE")]
36pub enum HttpMethod {
37    Get,
38    #[default]
39    Post,
40    Put,
41    Patch,
42    Delete,
43}
44
45/// Confirmation dialog shown before executing an action.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47pub struct ConfirmDialog {
48    pub title: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub message: Option<String>,
51    #[serde(default)]
52    pub variant: DialogVariant,
53}
54
55/// Notification variant for action outcomes.
56#[derive(
57    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
58)]
59#[serde(rename_all = "snake_case")]
60#[strum(serialize_all = "snake_case")]
61pub enum NotifyVariant {
62    #[default]
63    Success,
64    Info,
65    Warning,
66    Error,
67}
68
69/// Outcome after an action completes (success or error).
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum ActionOutcome {
73    Redirect {
74        url: String,
75    },
76    ShowErrors,
77    Refresh,
78    Notify {
79        message: String,
80        variant: NotifyVariant,
81    },
82}
83
84/// Handler reference for an [`Action`].
85///
86/// Accepts a literal string (handler name like `"users.store"` or absolute
87/// path like `"/users"`) or a `{"$data": "/path"}` binding resolved against
88/// `spec.data` at render time. The untagged enum keeps the wire format
89/// backward-compatible — existing literal handlers parse via `Literal`.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
91#[serde(untagged)]
92pub enum ActionHandler {
93    /// Handler name or literal URL. `"users.store"` is looked up via the
94    /// resolver; `"/users"` is passed through as the resolved URL.
95    Literal(String),
96    /// `{"$data": "/path"}` binding. Resolved against `spec.data` in
97    /// `resolve_actions`. The resolved string is then treated as a literal
98    /// handler (resolver lookup or pass-through for `/path` URLs).
99    Binding(DataRef),
100}
101
102impl ActionHandler {
103    /// Return the literal string when this handler is unbound.
104    ///
105    /// Returns `None` for `Binding` — callers that need the literal must
106    /// either run [`crate::resolve::resolve_actions`] first or branch on
107    /// the variant explicitly.
108    pub fn as_literal(&self) -> Option<&str> {
109        match self {
110            ActionHandler::Literal(s) => Some(s.as_str()),
111            ActionHandler::Binding(_) => None,
112        }
113    }
114
115    /// Return the underlying string regardless of variant.
116    ///
117    /// For `Literal`, this is the handler name or URL. For `Binding`, this
118    /// is the binding's `$data` JSON-pointer path — useful for diagnostic
119    /// rendering when the action has not been resolved.
120    pub fn as_str(&self) -> &str {
121        match self {
122            ActionHandler::Literal(s) => s.as_str(),
123            ActionHandler::Binding(d) => d.data.as_str(),
124        }
125    }
126}
127
128impl std::fmt::Display for ActionHandler {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134impl Default for ActionHandler {
135    fn default() -> Self {
136        ActionHandler::Literal(String::new())
137    }
138}
139
140impl From<String> for ActionHandler {
141    fn from(s: String) -> Self {
142        ActionHandler::Literal(s)
143    }
144}
145
146impl From<&str> for ActionHandler {
147    fn from(s: &str) -> Self {
148        ActionHandler::Literal(s.to_string())
149    }
150}
151
152/// An action declaration mapping a user interaction to a backend handler.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154pub struct Action {
155    /// Handler reference — literal `"controller.method"` / absolute `/path`,
156    /// or a `{"$data": "/path"}` binding resolved at render time.
157    pub handler: ActionHandler,
158    /// Resolved URL for this action. Populated by the resolver at render time.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub url: Option<String>,
161    #[serde(default)]
162    pub method: HttpMethod,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub confirm: Option<ConfirmDialog>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub on_success: Option<ActionOutcome>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub on_error: Option<ActionOutcome>,
169    /// Anchor target for navigation (GET) actions — e.g. "_blank" to open
170    /// in a new tab. When set, `rel="noopener noreferrer"` is added.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub target: Option<String>,
173}
174
175impl Action {
176    /// Create an action with Post method (the default for form submissions).
177    pub fn new(handler: impl Into<ActionHandler>) -> Self {
178        Self {
179            handler: handler.into(),
180            url: None,
181            method: HttpMethod::Post,
182            confirm: None,
183            on_success: None,
184            on_error: None,
185            target: None,
186        }
187    }
188
189    /// Create a navigation action with Get method.
190    pub fn get(handler: impl Into<ActionHandler>) -> Self {
191        Self {
192            method: HttpMethod::Get,
193            ..Self::new(handler)
194        }
195    }
196
197    /// Create a deletion action with Delete method.
198    pub fn delete(handler: impl Into<ActionHandler>) -> Self {
199        Self {
200            method: HttpMethod::Delete,
201            ..Self::new(handler)
202        }
203    }
204
205    /// Override the HTTP method.
206    pub fn method(mut self, method: HttpMethod) -> Self {
207        self.method = method;
208        self
209    }
210
211    /// Add a default confirmation dialog.
212    pub fn confirm(mut self, title: impl Into<String>) -> Self {
213        self.confirm = Some(ConfirmDialog {
214            title: title.into(),
215            message: None,
216            variant: DialogVariant::Default,
217        });
218        self
219    }
220
221    /// Add a danger confirmation dialog.
222    pub fn confirm_danger(mut self, title: impl Into<String>) -> Self {
223        self.confirm = Some(ConfirmDialog {
224            title: title.into(),
225            message: None,
226            variant: DialogVariant::Danger,
227        });
228        self
229    }
230
231    /// Set the success outcome.
232    pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
233        self.on_success = Some(outcome);
234        self
235    }
236
237    /// Set the error outcome.
238    pub fn on_error(mut self, outcome: ActionOutcome) -> Self {
239        self.on_error = Some(outcome);
240        self
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn minimal_action_serializes() {
250        let action = Action {
251            handler: ActionHandler::Literal("users.store".to_string()),
252            url: None,
253            method: HttpMethod::Post,
254            confirm: None,
255            on_success: None,
256            on_error: None,
257            target: None,
258        };
259        let json = serde_json::to_value(&action).unwrap();
260        assert_eq!(json["handler"], "users.store");
261        assert_eq!(json["method"], "POST");
262        assert!(json.get("confirm").is_none());
263        assert!(json.get("on_success").is_none());
264    }
265
266    #[test]
267    fn action_with_confirm_dialog() {
268        let action = Action {
269            handler: ActionHandler::Literal("users.destroy".to_string()),
270            url: None,
271            method: HttpMethod::Delete,
272            confirm: Some(ConfirmDialog {
273                title: "Delete user?".to_string(),
274                message: Some("This cannot be undone.".to_string()),
275                variant: DialogVariant::Danger,
276            }),
277            on_success: Some(ActionOutcome::Redirect {
278                url: "/users".to_string(),
279            }),
280            on_error: Some(ActionOutcome::ShowErrors),
281            target: None,
282        };
283        let json = serde_json::to_string(&action).unwrap();
284        let parsed: Action = serde_json::from_str(&json).unwrap();
285        assert_eq!(parsed, action);
286    }
287
288    #[test]
289    fn action_outcome_variants_serialize() {
290        let redirect = ActionOutcome::Redirect {
291            url: "/dashboard".to_string(),
292        };
293        let json = serde_json::to_value(&redirect).unwrap();
294        assert_eq!(json["type"], "redirect");
295        assert_eq!(json["url"], "/dashboard");
296
297        let show_errors = ActionOutcome::ShowErrors;
298        let json = serde_json::to_value(&show_errors).unwrap();
299        assert_eq!(json["type"], "show_errors");
300
301        let refresh = ActionOutcome::Refresh;
302        let json = serde_json::to_value(&refresh).unwrap();
303        assert_eq!(json["type"], "refresh");
304
305        let notify = ActionOutcome::Notify {
306            message: "Saved!".to_string(),
307            variant: NotifyVariant::Success,
308        };
309        let json = serde_json::to_value(&notify).unwrap();
310        assert_eq!(json["type"], "notify");
311        assert_eq!(json["message"], "Saved!");
312        assert_eq!(json["variant"], "success");
313    }
314
315    #[test]
316    fn http_method_defaults_to_post() {
317        let json = r#"{"handler": "posts.store"}"#;
318        let action: Action = serde_json::from_str(json).unwrap();
319        assert_eq!(action.method, HttpMethod::Post);
320    }
321
322    #[test]
323    fn dialog_variant_defaults_to_default() {
324        let json = r#"{"title": "Confirm?"}"#;
325        let dialog: ConfirmDialog = serde_json::from_str(json).unwrap();
326        assert_eq!(dialog.variant, DialogVariant::Default);
327    }
328
329    #[test]
330    fn action_without_url_omits_url_field() {
331        let action = Action {
332            handler: ActionHandler::Literal("users.index".to_string()),
333            url: None,
334            method: HttpMethod::Get,
335            confirm: None,
336            on_success: None,
337            on_error: None,
338            target: None,
339        };
340        let json = serde_json::to_value(&action).unwrap();
341        assert!(json.get("url").is_none(), "url should be omitted when None");
342    }
343
344    #[test]
345    fn action_with_url_includes_url_field() {
346        let action = Action {
347            handler: ActionHandler::Literal("users.store".to_string()),
348            url: Some("/users".to_string()),
349            method: HttpMethod::Post,
350            confirm: None,
351            on_success: None,
352            on_error: None,
353            target: None,
354        };
355        let json = serde_json::to_value(&action).unwrap();
356        assert_eq!(json["url"], "/users");
357    }
358
359    #[test]
360    fn action_url_round_trips() {
361        let action = Action {
362            handler: ActionHandler::Literal("users.show".to_string()),
363            url: Some("/users/42".to_string()),
364            method: HttpMethod::Get,
365            confirm: None,
366            on_success: None,
367            on_error: None,
368            target: None,
369        };
370        let json = serde_json::to_string(&action).unwrap();
371        let parsed: Action = serde_json::from_str(&json).unwrap();
372        assert_eq!(parsed.url, Some("/users/42".to_string()));
373        assert_eq!(parsed, action);
374    }
375
376    // ── Builder method tests ──────────────────────────────────────────
377
378    #[test]
379    fn builder_new_creates_post_action() {
380        let action = Action::new("users.store");
381        assert_eq!(action.handler.as_str(), "users.store");
382        assert_eq!(action.method, HttpMethod::Post);
383        assert_eq!(action.url, None);
384        assert_eq!(action.confirm, None);
385        assert_eq!(action.on_success, None);
386        assert_eq!(action.on_error, None);
387    }
388
389    #[test]
390    fn builder_get_creates_get_action() {
391        let action = Action::get("users.index");
392        assert_eq!(action.handler.as_str(), "users.index");
393        assert_eq!(action.method, HttpMethod::Get);
394    }
395
396    #[test]
397    fn builder_delete_creates_delete_action() {
398        let action = Action::delete("users.destroy");
399        assert_eq!(action.handler.as_str(), "users.destroy");
400        assert_eq!(action.method, HttpMethod::Delete);
401    }
402
403    // ── F14: ActionHandler binding tests ───────────────────────────────
404
405    #[test]
406    fn action_handler_literal_round_trips_as_string() {
407        let json = r#"{"handler":"users.store"}"#;
408        let action: Action = serde_json::from_str(json).unwrap();
409        assert!(matches!(action.handler, ActionHandler::Literal(ref s) if s == "users.store"));
410        let back = serde_json::to_string(&action).unwrap();
411        assert!(back.contains(r#""handler":"users.store""#));
412    }
413
414    #[test]
415    fn action_handler_binding_parses_data_ref() {
416        let json = r#"{"handler":{"$data":"/cell/action_url"}}"#;
417        let action: Action = serde_json::from_str(json).unwrap();
418        assert!(matches!(
419            action.handler,
420            ActionHandler::Binding(ref d) if d.data == "/cell/action_url"
421        ));
422    }
423
424    #[test]
425    fn action_handler_binding_round_trips_via_serde() {
426        let json = r#"{"handler":{"$data":"/path"},"method":"GET"}"#;
427        let parsed: Action = serde_json::from_str(json).unwrap();
428        let back = serde_json::to_string(&parsed).unwrap();
429        assert!(back.contains(r#""handler":{"$data":"/path"}"#));
430    }
431
432    #[test]
433    fn action_handler_as_str_returns_binding_path() {
434        let action: Action = serde_json::from_str(r#"{"handler":{"$data":"/foo"}}"#).unwrap();
435        assert_eq!(action.handler.as_str(), "/foo");
436        assert!(action.handler.as_literal().is_none());
437    }
438
439    #[test]
440    fn action_handler_display_uses_underlying_string() {
441        let lit = ActionHandler::Literal("users.show".to_string());
442        let bind = ActionHandler::Binding(DataRef {
443            data: "/x".to_string(),
444        });
445        assert_eq!(format!("{lit}"), "users.show");
446        assert_eq!(format!("{bind}"), "/x");
447    }
448
449    #[test]
450    fn builder_confirm_adds_default_dialog() {
451        let action = Action::new("users.store").confirm("Save changes?");
452        let dialog = action.confirm.unwrap();
453        assert_eq!(dialog.title, "Save changes?");
454        assert_eq!(dialog.variant, DialogVariant::Default);
455        assert_eq!(dialog.message, None);
456    }
457
458    #[test]
459    fn builder_confirm_danger_adds_danger_dialog() {
460        let action = Action::delete("users.destroy").confirm_danger("Delete user?");
461        let dialog = action.confirm.unwrap();
462        assert_eq!(dialog.title, "Delete user?");
463        assert_eq!(dialog.variant, DialogVariant::Danger);
464    }
465
466    #[test]
467    fn builder_on_success_sets_outcome() {
468        let action = Action::new("users.store").on_success(ActionOutcome::Refresh);
469        assert_eq!(action.on_success, Some(ActionOutcome::Refresh));
470    }
471
472    #[test]
473    fn builder_on_error_sets_outcome() {
474        let action = Action::new("users.store").on_error(ActionOutcome::ShowErrors);
475        assert_eq!(action.on_error, Some(ActionOutcome::ShowErrors));
476    }
477
478    #[test]
479    fn builder_chain_produces_expected_json() {
480        let action = Action::delete("users.destroy")
481            .confirm_danger("Delete user?")
482            .on_success(ActionOutcome::Refresh);
483
484        let json = serde_json::to_value(&action).unwrap();
485        assert_eq!(json["handler"], "users.destroy");
486        assert_eq!(json["method"], "DELETE");
487        assert_eq!(json["confirm"]["title"], "Delete user?");
488        assert_eq!(json["confirm"]["variant"], "danger");
489        assert_eq!(json["on_success"]["type"], "refresh");
490    }
491
492    #[test]
493    fn builder_method_overrides() {
494        let action = Action::new("users.update").method(HttpMethod::Put);
495        assert_eq!(action.method, HttpMethod::Put);
496    }
497
498    /// Assert AsRef<str> matches serde JSON wire format for DialogVariant and NotifyVariant.
499    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
500    #[test]
501    fn dialog_notify_variant_strum_matches_serde() {
502        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
503            for v in variants {
504                let json = serde_json::to_string(v).expect("serialize");
505                assert_eq!(v.as_ref(), json.trim_matches('"'), "{label} strum drift");
506            }
507        }
508        check(
509            &[DialogVariant::Default, DialogVariant::Danger],
510            "DialogVariant",
511        );
512        check(
513            &[
514                NotifyVariant::Success,
515                NotifyVariant::Warning,
516                NotifyVariant::Error,
517                NotifyVariant::Info,
518            ],
519            "NotifyVariant",
520        );
521    }
522}