1use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::spec::DataRef;
20
21#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
91#[serde(untagged)]
92pub enum ActionHandler {
93 Literal(String),
96 Binding(DataRef),
100}
101
102impl ActionHandler {
103 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154pub struct Action {
155 pub handler: ActionHandler,
158 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub target: Option<String>,
173}
174
175impl Action {
176 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 pub fn get(handler: impl Into<ActionHandler>) -> Self {
191 Self {
192 method: HttpMethod::Get,
193 ..Self::new(handler)
194 }
195 }
196
197 pub fn delete(handler: impl Into<ActionHandler>) -> Self {
199 Self {
200 method: HttpMethod::Delete,
201 ..Self::new(handler)
202 }
203 }
204
205 pub fn method(mut self, method: HttpMethod) -> Self {
207 self.method = method;
208 self
209 }
210
211 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 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 pub fn on_success(mut self, outcome: ActionOutcome) -> Self {
233 self.on_success = Some(outcome);
234 self
235 }
236
237 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(¬ify).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 #[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 #[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 #[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}