1use std::collections::HashMap;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::component::Tone;
22use crate::spec::DataRef;
23
24#[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#[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 #[serde(flatten)]
49 pub(crate) unknown_fields: HashMap<String, serde_json::Value>,
50}
51
52fn default_notify_tone() -> Tone {
55 Tone::Success
56}
57
58#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81#[serde(untagged)]
82pub enum ActionHandler {
83 Literal(String),
86 Binding(DataRef),
90}
91
92impl ActionHandler {
93 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
144pub struct Action {
145 pub handler: ActionHandler,
148 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub target: Option<String>,
163}
164
165impl Action {
166 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 pub fn get(handler: impl Into<ActionHandler>) -> Self {
181 Self {
182 method: HttpMethod::Get,
183 ..Self::new(handler)
184 }
185 }
186
187 pub fn delete(handler: impl Into<ActionHandler>) -> Self {
189 Self {
190 method: HttpMethod::Delete,
191 ..Self::new(handler)
192 }
193 }
194
195 pub fn method(mut self, method: HttpMethod) -> Self {
197 self.method = method;
198 self
199 }
200
201 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 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 pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
225 self.on_success = Some(outcome);
226 self
227 }
228
229 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(¬ify).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 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 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 #[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 #[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 #[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}