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