1use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_criteria::{Criteria, Environment, Scope, Value};
9use ravenrs_reactive::State;
10
11use crate::frame::{FrameContext, ViewInfo, ViewKind};
12use crate::{EntityInfo, FieldKind, ModelError, Object, ObjectSpace};
13
14pub const REASON_SCOPE: &str = "scope";
16pub const REASON_CONTROLLER: &str = "controller";
18pub const REASON_SELECTION: &str = "selection";
20pub const REASON_CRITERIA: &str = "criteria";
22
23pub const REASON_APPEARANCE: &str = "appearance";
25pub const REASON_SECURITY: &str = "security";
27pub const REASON_RUNNING: &str = "running";
29
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
33#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
34pub struct Reasons(BTreeMap<String, bool>);
35
36#[cfg_attr(feature = "live", ravenrs_live::expose)]
37impl Reasons {
38 pub fn all(&self) -> bool {
40 self.0.values().all(|on| *on)
41 }
42
43 pub fn set(&mut self, reason: &str, on: bool) {
45 self.0.insert(reason.to_owned(), on);
46 }
47
48 pub fn get(&self, reason: &str) -> Option<bool> {
50 self.0.get(reason).copied()
51 }
52
53 pub fn against(&self) -> impl Iterator<Item = &str> {
55 self.0.iter().filter(|(_, on)| !**on).map(|(reason, _)| reason.as_str())
56 }
57}
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
62pub enum Selection {
63 #[default]
65 Independent,
66 RequireSingle,
68 RequireMultiple,
70}
71
72#[cfg_attr(feature = "live", ravenrs_live::expose)]
73impl Selection {
74 fn allows(self, count: usize) -> bool {
75 match self {
76 Self::Independent => true,
77 Self::RequireSingle => count == 1,
78 Self::RequireMultiple => count >= 1,
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
86pub enum CriteriaMode {
87 #[default]
89 TrueForAll,
90 TrueAtLeastForOne,
92}
93
94#[derive(Clone, Debug, Default, PartialEq, Eq)]
96#[non_exhaustive]
97#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
98pub enum ActionCategory {
99 New,
101 #[default]
103 Edit,
104 Save,
106 Filter,
108 Navigation,
110 View,
112 Export,
114 Custom(String),
116 InForm(String),
119}
120
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
124pub enum Nesting {
125 #[default]
127 Any,
128 Root,
130 Nested,
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq)]
136#[non_exhaustive]
137#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
138pub struct TargetScope {
139 pub entity: Option<String>,
141 pub view_id: Option<String>,
143 pub view_kind: Option<ViewKind>,
145 pub nesting: Nesting,
147}
148
149#[cfg_attr(feature = "live", ravenrs_live::expose)]
150impl TargetScope {
151 pub fn any() -> Self {
153 Self::default()
154 }
155
156 pub fn entity(mut self, entity: impl Into<String>) -> Self {
158 self.entity = Some(entity.into());
159 self
160 }
161
162 pub fn view_id(mut self, id: impl Into<String>) -> Self {
164 self.view_id = Some(id.into());
165 self
166 }
167
168 pub fn view_kind(mut self, kind: ViewKind) -> Self {
170 self.view_kind = Some(kind);
171 self
172 }
173
174 pub fn nesting(mut self, nesting: Nesting) -> Self {
176 self.nesting = nesting;
177 self
178 }
179
180 pub fn covers(&self, view: Option<&ViewInfo>, context: FrameContext) -> bool {
183 let nesting = match self.nesting {
184 Nesting::Any => true,
185 Nesting::Root => context != FrameContext::Nested,
186 Nesting::Nested => context == FrameContext::Nested,
187 };
188 nesting
189 && match view {
190 Some(view) => {
191 self.entity.as_ref().is_none_or(|e| view.entity.as_ref() == Some(e))
192 && self.view_id.as_ref().is_none_or(|id| *id == view.id)
193 && self.view_kind.is_none_or(|kind| kind == view.kind)
194 }
195 None => self.entity.is_none() && self.view_id.is_none() && self.view_kind.is_none(),
196 }
197 }
198}
199
200#[derive(Clone, Debug, PartialEq, Eq)]
202#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
203pub struct ChoiceItem {
204 pub id: String,
206 pub caption: String,
208 pub items: Vec<ChoiceItem>,
210}
211
212#[cfg_attr(feature = "live", ravenrs_live::expose)]
213impl ChoiceItem {
214 pub fn new(id: impl Into<String>, caption: impl Into<String>) -> Self {
216 Self { id: id.into(), caption: caption.into(), items: Vec::new() }
217 }
218
219 pub fn item(mut self, item: ChoiceItem) -> Self {
221 self.items.push(item);
222 self
223 }
224
225 fn has(&self, id: &str) -> bool {
226 self.id == id || self.items.iter().any(|item| item.has(id))
227 }
228}
229
230#[derive(Clone, Debug, PartialEq)]
232#[non_exhaustive]
233#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
234pub enum ActionKind {
235 Simple,
237 Popup {
239 view: String,
241 },
242 SingleChoice(Vec<ChoiceItem>),
244 Parametrized(FieldKind),
246 Url(String),
248}
249
250#[derive(Clone, Debug, PartialEq)]
252#[non_exhaustive]
253#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
254pub enum ActionResult {
255 Done,
257 Popup(String),
260 ShowView(String),
262 OpenUrl(String),
264 RecordsChanged,
266 Started(u64),
268}
269
270#[derive(Clone, Debug, Default, PartialEq)]
272#[non_exhaustive]
273#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
274pub enum ActionInput {
275 #[default]
277 None,
278 Choice(String),
280 Parameter(Value),
282 Record(Object),
284}
285
286#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
288#[non_exhaustive]
289#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
290pub enum MessageKind {
291 #[default]
293 Information,
294 Success,
296 Warning,
298 Error,
300}
301
302#[derive(Clone)]
304#[non_exhaustive]
305#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
306pub struct Message {
307 pub text: String,
309 pub kind: MessageKind,
311 pub seconds: Option<f64>,
313 pub action: Option<(String, Rc<dyn Fn()>)>,
315 pub on_close: Option<Rc<dyn Fn()>>,
317}
318
319impl fmt::Debug for Message {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 f.debug_struct("Message").field("text", &self.text).field("kind", &self.kind).finish_non_exhaustive()
322 }
323}
324
325#[cfg_attr(feature = "live", ravenrs_live::expose)]
326impl Message {
327 pub fn new(text: impl Into<String>, kind: MessageKind) -> Self {
329 Self { text: text.into(), kind, seconds: None, action: None, on_close: None }
330 }
331
332 pub fn seconds(mut self, seconds: f64) -> Self {
334 self.seconds = Some(seconds);
335 self
336 }
337
338 pub fn action(mut self, label: impl Into<String>, then: impl Fn() + 'static) -> Self {
340 self.action = Some((label.into(), Rc::new(then)));
341 self
342 }
343
344 pub fn on_close(mut self, then: impl Fn() + 'static) -> Self {
346 self.on_close = Some(Rc::new(then));
347 self
348 }
349}
350
351#[derive(Clone)]
353#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
354pub struct ActionContext {
355 pub frame: crate::Frame,
357 pub space: Rc<RefCell<dyn ObjectSpace>>,
359 pub selected: Vec<Object>,
361 pub view: Option<ViewInfo>,
363 pub input: ActionInput,
365 next: Rc<RefCell<Option<ActionResult>>>,
366}
367
368impl fmt::Debug for ActionContext {
369 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 f.debug_struct("ActionContext").field("selected", &self.selected.len()).finish_non_exhaustive()
371 }
372}
373
374#[cfg_attr(feature = "live", ravenrs_live::expose)]
375impl ActionContext {
376 pub(crate) fn new(
377 frame: crate::Frame,
378 space: Rc<RefCell<dyn ObjectSpace>>,
379 selected: Vec<Object>,
380 view: Option<ViewInfo>,
381 input: ActionInput,
382 ) -> Self {
383 Self { frame, space, selected, view, input, next: Rc::default() }
384 }
385
386 pub fn service<T: 'static>(&self) -> Option<Rc<T>> {
388 self.frame.extension::<T>()
389 }
390
391 pub fn message(&self, message: Message) {
393 self.frame.show_message(message);
394 }
395
396 pub fn show_view(&self, id: impl Into<String>) {
398 *self.next.borrow_mut() = Some(ActionResult::ShowView(id.into()));
399 }
400
401 pub fn records_changed(&self) {
403 *self.next.borrow_mut() = Some(ActionResult::RecordsChanged);
404 }
405
406 pub(crate) fn take_next(&self) -> Option<ActionResult> {
407 self.next.borrow_mut().take()
408 }
409}
410
411#[derive(Clone, Debug, PartialEq, Eq)]
413#[non_exhaustive]
414#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
415pub enum ActionError {
416 Unknown(String),
418 Inactive(Vec<String>),
420 Disabled(Vec<String>),
422 Canceled,
424 BadInput(String),
426 Failed(String),
428}
429
430impl fmt::Display for ActionError {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 match self {
433 Self::Unknown(id) => write!(f, "there is no action {id}"),
434 Self::Inactive(why) => write!(f, "the action is out of place: {}", why.join(", ")),
435 Self::Disabled(why) => write!(f, "the action is off: {}", why.join(", ")),
436 Self::Canceled => f.write_str("the action was canceled"),
437 Self::BadInput(why) | Self::Failed(why) => f.write_str(why),
438 }
439 }
440}
441
442impl std::error::Error for ActionError {}
443
444type Handler = Rc<dyn Fn(&ActionContext) -> Result<(), String>>;
445type Guard = Rc<dyn Fn(&ActionContext) -> bool>;
446type Notice = Rc<dyn Fn(&ActionContext)>;
447type Prepare = Rc<dyn Fn(&ActionContext, &mut Object)>;
448
449#[derive(Clone)]
450struct ActionInner {
451 id: String,
452 caption: String,
453 kind: ActionKind,
454 category: ActionCategory,
455 scope: TargetScope,
456 selection: Selection,
457 criteria: Option<(String, CriteriaMode)>,
458 validation_contexts: Vec<String>,
459 confirmation: Option<String>,
460 confirmation_detail: Option<String>,
461 confirmation_answer: Option<String>,
462 prepare: Option<Prepare>,
463 hotkey: Option<String>,
464 icon: Option<String>,
465 accessible_name: Option<String>,
466 accessible_description: Option<String>,
467 requires: Option<crate::Operation>,
468 on_execute: Option<Handler>,
469 on_cancel: Option<Notice>,
470 on_executing: Option<Guard>,
471 on_executed: Option<Notice>,
472 background: Option<crate::JobWork>,
473 problem: Option<String>,
474 active: State<Reasons>,
475 enabled: State<Reasons>,
476}
477
478#[derive(Clone)]
500#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
501pub struct Action(Rc<ActionInner>);
502
503impl fmt::Debug for Action {
504 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505 f.debug_struct("Action").field("id", &self.0.id).finish_non_exhaustive()
506 }
507}
508
509#[cfg_attr(feature = "live", ravenrs_live::expose)]
510impl Action {
511 fn new(id: impl Into<String>, caption: impl Into<String>, kind: ActionKind) -> Self {
512 Self(Rc::new(ActionInner {
513 id: id.into(),
514 caption: caption.into(),
515 kind,
516 category: ActionCategory::default(),
517 scope: TargetScope::default(),
518 selection: Selection::default(),
519 criteria: None,
520 validation_contexts: Vec::new(),
521 confirmation: None,
522 confirmation_detail: None,
523 confirmation_answer: None,
524 prepare: None,
525 hotkey: None,
526 requires: None,
527 icon: None,
528 accessible_name: None,
529 accessible_description: None,
530 on_execute: None,
531 on_cancel: None,
532 on_executing: None,
533 on_executed: None,
534 background: None,
535 problem: None,
536 active: State::new(Reasons::default()),
537 enabled: State::new(Reasons::default()),
538 }))
539 }
540
541 pub fn simple(id: impl Into<String>, caption: impl Into<String>) -> Self {
543 Self::new(id, caption, ActionKind::Simple)
544 }
545
546 pub fn background(
565 id: impl Into<String>,
566 caption: impl Into<String>,
567 work: impl Fn(&crate::JobContext) -> Result<(), String> + Send + Sync + 'static,
568 ) -> Self {
569 Self::simple(id, caption).in_background(work)
570 }
571
572 pub fn popup(id: impl Into<String>, caption: impl Into<String>, view: impl Into<String>) -> Self {
574 Self::new(id, caption, ActionKind::Popup { view: view.into() })
575 }
576
577 pub fn single_choice(id: impl Into<String>, caption: impl Into<String>, items: Vec<ChoiceItem>) -> Self {
579 Self::new(id, caption, ActionKind::SingleChoice(items))
580 }
581
582 pub fn parametrized(id: impl Into<String>, caption: impl Into<String>, kind: FieldKind) -> Self {
584 Self::new(id, caption, ActionKind::Parametrized(kind))
585 }
586
587 pub fn url(id: impl Into<String>, caption: impl Into<String>, template: impl Into<String>) -> Self {
589 Self::new(id, caption, ActionKind::Url(template.into()))
590 }
591
592 fn change(mut self, f: impl FnOnce(&mut ActionInner)) -> Self {
593 if Rc::get_mut(&mut self.0).is_none() {
597 let mut copy = (*self.0).clone();
598 copy.active = State::new(self.0.active.get());
599 copy.enabled = State::new(self.0.enabled.get());
600 self.0 = Rc::new(copy);
601 }
602 if let Some(inner) = Rc::get_mut(&mut self.0) {
603 f(inner);
604 }
605 self
606 }
607
608 pub fn in_background(
611 self,
612 work: impl Fn(&crate::JobContext) -> Result<(), String> + Send + Sync + 'static,
613 ) -> Self {
614 self.change(|a| a.background = Some(std::sync::Arc::new(work)))
615 }
616
617 pub fn is_background(&self) -> bool {
619 self.0.background.is_some()
620 }
621
622 pub(crate) fn work(&self) -> Option<crate::JobWork> {
623 self.0.background.clone()
624 }
625
626 pub fn category(self, category: ActionCategory) -> Self {
628 self.change(|a| a.category = category)
629 }
630
631 pub fn target_entity(self, entity: impl Into<String>) -> Self {
633 self.change(|a| a.scope.entity = Some(entity.into()))
634 }
635
636 pub fn target_view(self, id: impl Into<String>) -> Self {
638 self.change(|a| a.scope.view_id = Some(id.into()))
639 }
640
641 pub fn target_view_kind(self, kind: ViewKind) -> Self {
643 self.change(|a| a.scope.view_kind = Some(kind))
644 }
645
646 pub fn nesting(self, nesting: Nesting) -> Self {
648 self.change(|a| a.scope.nesting = nesting)
649 }
650
651 pub fn selection(self, selection: Selection) -> Self {
653 self.change(|a| a.selection = selection)
654 }
655
656 pub fn target_criteria(self, criteria: impl Into<String>, mode: CriteriaMode) -> Self {
659 self.change(|a| a.criteria = Some((criteria.into(), mode)))
660 }
661
662 pub fn validation_contexts(self, contexts: impl IntoIterator<Item = impl Into<String>>) -> Self {
664 self.change(|a| a.validation_contexts = contexts.into_iter().map(Into::into).collect())
665 }
666
667 pub fn confirmation(self, text: impl Into<String>) -> Self {
669 self.change(|a| a.confirmation = Some(text.into()))
670 }
671
672 pub fn confirmation_words(self, detail: impl Into<String>, answer: impl Into<String>) -> Self {
674 self.change(|a| {
675 a.confirmation_detail = Some(detail.into());
676 a.confirmation_answer = Some(answer.into());
677 })
678 }
679
680 pub fn on_prepare(self, handler: impl Fn(&ActionContext, &mut Object) + 'static) -> Self {
682 self.change(|a| a.prepare = Some(Rc::new(handler)))
683 }
684
685 pub fn requires(self, operation: crate::Operation) -> Self {
688 self.change(|a| a.requires = Some(operation))
689 }
690
691 pub fn hotkey(self, keys: impl Into<String>) -> Self {
693 self.change(|a| a.hotkey = Some(keys.into()))
694 }
695
696 pub fn icon(self, icon: impl Into<String>) -> Self {
698 self.change(|a| a.icon = Some(icon.into()))
699 }
700
701 pub fn accessible_name(self, name: impl Into<String>) -> Self {
704 self.change(|a| a.accessible_name = Some(name.into()))
705 }
706
707 pub fn accessible_description(self, text: impl Into<String>) -> Self {
709 self.change(|a| a.accessible_description = Some(text.into()))
710 }
711
712 pub fn on_execute(self, handler: impl Fn(&ActionContext) -> Result<(), String> + 'static) -> Self {
714 self.change(|a| a.on_execute = Some(Rc::new(handler)))
715 }
716
717 pub fn assign(self, entity: &EntityInfo, set: &str) -> Self {
735 match crate::record_action::assignments(entity, set) {
736 Ok(found) => {
737 let entity = entity.clone();
738 self.on_execute(move |cx| crate::record_action::assign(&entity, &found, cx))
739 }
740 Err(problem) => self.change(|a| a.problem = Some(problem)),
741 }
742 }
743
744 pub fn on_cancel(self, handler: impl Fn(&ActionContext) + 'static) -> Self {
746 self.change(|a| a.on_cancel = Some(Rc::new(handler)))
747 }
748
749 pub fn on_executing(self, guard: impl Fn(&ActionContext) -> bool + 'static) -> Self {
751 self.change(|a| a.on_executing = Some(Rc::new(guard)))
752 }
753
754 pub fn on_executed(self, notice: impl Fn(&ActionContext) + 'static) -> Self {
756 self.change(|a| a.on_executed = Some(Rc::new(notice)))
757 }
758
759 pub fn id(&self) -> &str {
761 &self.0.id
762 }
763
764 pub fn caption(&self) -> &str {
766 &self.0.caption
767 }
768
769 pub fn kind(&self) -> &ActionKind {
771 &self.0.kind
772 }
773
774 pub fn action_category(&self) -> &ActionCategory {
776 &self.0.category
777 }
778
779 pub fn confirmation_text(&self) -> Option<&str> {
781 self.0.confirmation.as_deref()
782 }
783
784 pub fn confirmation_detail(&self) -> Option<&str> {
786 self.0.confirmation_detail.as_deref()
787 }
788
789 pub fn confirmation_answer(&self) -> Option<&str> {
791 self.0.confirmation_answer.as_deref()
792 }
793
794 pub(crate) fn run_prepare(&self, cx: &ActionContext, object: &mut Object) {
795 if let Some(prepare) = &self.0.prepare {
796 prepare(cx, object);
797 }
798 }
799
800 pub fn required_operation(&self) -> Option<crate::Operation> {
802 self.0.requires
803 }
804
805 pub fn hotkey_text(&self) -> Option<&str> {
807 self.0.hotkey.as_deref()
808 }
809
810 pub fn icon_name(&self) -> Option<&str> {
812 self.0.icon.as_deref()
813 }
814
815 pub fn accessible_name_text(&self) -> Option<&str> {
817 self.0.accessible_name.as_deref()
818 }
819
820 pub fn accessible_description_text(&self) -> Option<&str> {
822 self.0.accessible_description.as_deref()
823 }
824
825 pub fn contexts(&self) -> &[String] {
827 &self.0.validation_contexts
828 }
829
830 pub fn is_active(&self) -> bool {
832 self.0.active.with(Reasons::all)
833 }
834
835 pub fn is_enabled(&self) -> bool {
837 self.0.enabled.with(Reasons::all)
838 }
839
840 pub fn active_reasons(&self) -> Reasons {
842 self.0.active.get()
843 }
844
845 pub fn enabled_reasons(&self) -> Reasons {
847 self.0.enabled.get()
848 }
849
850 pub fn set_active(&self, reason: &str, on: bool) {
852 if self.0.active.peek().get(reason) != Some(on) {
853 self.0.active.update(|reasons| reasons.set(reason, on));
854 }
855 }
856
857 pub fn set_enabled(&self, reason: &str, on: bool) {
861 if self.0.enabled.peek().get(reason) != Some(on) {
862 self.0.enabled.update(|reasons| reasons.set(reason, on));
863 }
864 }
865
866 pub(crate) fn check(&self) -> Result<(), ModelError> {
868 if let Some(problem) = &self.0.problem {
869 return Err(ModelError(format!("the action {}: {problem}", self.0.id)));
870 }
871 if let Some((text, _)) = &self.0.criteria {
872 Criteria::parse(text).map_err(|e| ModelError(format!("the action {}: {e}", self.0.id)))?;
873 }
874 Ok(())
875 }
876
877 pub(crate) fn update(
879 &self,
880 view: Option<&ViewInfo>,
881 context: FrameContext,
882 selected: &[Object],
883 environment: &Environment,
884 ) {
885 self.set_active(REASON_SCOPE, self.0.scope.covers(view, context));
886 self.set_enabled(REASON_SELECTION, self.0.selection.allows(selected.len()));
887 self.set_enabled(REASON_CRITERIA, self.meets_criteria(selected, environment));
888 }
889
890 fn meets_criteria(&self, selected: &[Object], environment: &Environment) -> bool {
891 let Some((text, mode)) = &self.0.criteria else { return true };
892 if selected.is_empty() {
893 return true;
894 }
895 let Ok(criteria) = Criteria::parse(text) else { return false };
897 let meets = |object: &Object| Scope::new(environment).row(object).matches(&criteria);
898 match mode {
899 CriteriaMode::TrueForAll => selected.iter().all(meets),
900 CriteriaMode::TrueAtLeastForOne => selected.iter().any(meets),
901 }
902 }
903
904 pub(crate) fn check_input(&self, input: &ActionInput) -> Result<(), ActionError> {
906 match (&self.0.kind, input) {
907 (ActionKind::SingleChoice(items), ActionInput::Choice(id)) => {
908 if items.is_empty() || items.iter().any(|item| item.has(id)) {
910 Ok(())
911 } else {
912 Err(ActionError::BadInput(format!("{} has no option {id}", self.0.id)))
913 }
914 }
915 (ActionKind::SingleChoice(_), _) => {
916 Err(ActionError::BadInput(format!("{} needs an option", self.0.id)))
917 }
918 (ActionKind::Parametrized(_), ActionInput::Parameter(_)) => Ok(()),
919 (ActionKind::Parametrized(_), _) => {
920 Err(ActionError::BadInput(format!("{} needs a value", self.0.id)))
921 }
922 _ => Ok(()),
923 }
924 }
925
926 pub(crate) fn run_guard(&self, cx: &ActionContext) -> bool {
927 self.0.on_executing.as_ref().is_none_or(|guard| guard(cx))
928 }
929
930 pub(crate) fn run(&self, cx: &ActionContext) -> Result<(), String> {
931 self.0.on_execute.as_ref().map_or(Ok(()), |handler| handler(cx))
932 }
933
934 pub(crate) fn run_notice(&self, cx: &ActionContext) {
935 if let Some(notice) = &self.0.on_executed {
936 notice(cx);
937 }
938 }
939
940 pub(crate) fn run_cancel(&self, cx: &ActionContext) {
941 if let Some(notice) = &self.0.on_cancel {
942 notice(cx);
943 }
944 }
945}
946
947fn encoded(value: &Value) -> String {
949 let text = match value {
950 Value::Null => String::new(),
951 Value::Text(text) => text.clone(),
952 Value::Number(number) if number.fract() == 0.0 => format!("{}", *number as i64),
953 Value::Number(number) => number.to_string(),
954 Value::Bool(on) => on.to_string(),
955 other => format!("{other:?}"),
956 };
957 let mut out = String::with_capacity(text.len());
958 for byte in text.bytes() {
959 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
960 out.push(char::from(byte));
961 } else {
962 out.push_str(&format!("%{byte:02X}"));
963 }
964 }
965 out
966}
967
968pub(crate) fn fill_url(template: &str, object: Option<&Object>) -> String {
970 let mut out = String::with_capacity(template.len());
971 let mut rest = template;
972 while let Some(start) = rest.find('{') {
973 out.push_str(&rest[..start]);
974 let after = &rest[start + 1..];
975 let Some(end) = after.find('}') else {
976 out.push_str(&rest[start..]);
977 return out;
978 };
979 out.push_str(&encoded(&object.map_or(Value::Null, |o| o.get(&after[..end]))));
980 rest = &after[end + 1..];
981 }
982 out.push_str(rest);
983 out
984}
985
986#[cfg(test)]
987mod tests {
988 use std::collections::BTreeMap;
989
990 use super::*;
991
992 fn object(status: &str) -> Object {
993 let values = BTreeMap::from([("Status".to_owned(), Value::Text(status.to_owned()))]);
994 Object::from_values("Bill", values)
995 }
996
997 fn view(id: &str, kind: ViewKind, entity: Option<&str>) -> ViewInfo {
998 ViewInfo::new(id, kind, entity)
999 }
1000
1001 #[test]
1002 fn a_copy_kept_on_the_way_does_not_swallow_a_change() {
1003 let first = Action::simple("a", "a");
1004 let kept = first.clone();
1005 kept.set_enabled("mine", false);
1006 let changed = first.selection(Selection::RequireSingle);
1007 assert_eq!(changed.0.selection, Selection::RequireSingle);
1008 assert_eq!(kept.0.selection, Selection::Independent, "the copy stays as it was");
1009 assert!(!changed.is_enabled(), "it starts with the same reasons");
1010 changed.set_enabled("mine", true);
1011 assert!(!kept.is_enabled(), "and does not share them");
1012 }
1013
1014 #[test]
1015 fn every_reason_has_to_say_yes() {
1016 let mut reasons = Reasons::default();
1017 assert!(reasons.all(), "with none, nothing says no");
1018 reasons.set("a", true);
1019 reasons.set("b", false);
1020 assert!(!reasons.all());
1021 assert_eq!(reasons.against().collect::<Vec<_>>(), ["b"]);
1022 reasons.set("b", true);
1023 assert!(reasons.all());
1024 }
1025
1026 #[test]
1027 fn each_part_of_the_scope_narrows_it() {
1028 let list = view("Bill_ListView", ViewKind::List, Some("Bill"));
1029 let main = FrameContext::Main;
1030 assert!(TargetScope::any().covers(Some(&list), main));
1031 assert!(TargetScope::any().entity("Bill").covers(Some(&list), main));
1032 assert!(!TargetScope::any().entity("Other").covers(Some(&list), main));
1033 assert!(TargetScope::any().view_id("Bill_ListView").covers(Some(&list), main));
1034 assert!(!TargetScope::any().view_id("Other").covers(Some(&list), main));
1035 assert!(TargetScope::any().view_kind(ViewKind::List).covers(Some(&list), main));
1036 assert!(!TargetScope::any().view_kind(ViewKind::Detail).covers(Some(&list), main));
1037 assert!(TargetScope::any().nesting(Nesting::Root).covers(Some(&list), main));
1038 assert!(!TargetScope::any().nesting(Nesting::Nested).covers(Some(&list), main));
1039 assert!(!TargetScope::any().nesting(Nesting::Root).covers(Some(&list), FrameContext::Nested));
1040 assert!(TargetScope::any().nesting(Nesting::Nested).covers(Some(&list), FrameContext::Nested));
1041 assert!(TargetScope::any().entity("Bill").view_kind(ViewKind::List).covers(Some(&list), main));
1042 assert!(!TargetScope::any().entity("Bill").view_kind(ViewKind::Detail).covers(Some(&list), main));
1043 }
1044
1045 #[test]
1046 fn with_no_view_only_what_asks_nothing_of_one_applies() {
1047 assert!(TargetScope::any().covers(None, FrameContext::Main));
1048 assert!(!TargetScope::any().entity("Bill").covers(None, FrameContext::Main));
1049 assert!(!TargetScope::any().view_kind(ViewKind::List).covers(None, FrameContext::Main));
1050 }
1051
1052 #[test]
1053 fn selection_counts_what_is_selected() {
1054 let enabled = |selection: Selection, count: usize| selection.allows(count);
1055 assert!(enabled(Selection::Independent, 0));
1056 assert!(enabled(Selection::Independent, 5));
1057 assert!(!enabled(Selection::RequireSingle, 0));
1058 assert!(enabled(Selection::RequireSingle, 1));
1059 assert!(!enabled(Selection::RequireSingle, 2));
1060 assert!(!enabled(Selection::RequireMultiple, 0));
1061 assert!(enabled(Selection::RequireMultiple, 1));
1062 assert!(enabled(Selection::RequireMultiple, 2));
1063 }
1064
1065 #[test]
1066 fn criteria_count_all_or_at_least_one() {
1067 let env = Environment::new();
1068 let open = "[Status] = 'Open'";
1069 let all = Action::simple("a", "a").target_criteria(open, CriteriaMode::TrueForAll);
1070 let one = Action::simple("b", "b").target_criteria(open, CriteriaMode::TrueAtLeastForOne);
1071 let mixed = [object("Open"), object("Paid")];
1072 assert!(!all.meets_criteria(&mixed, &env));
1073 assert!(one.meets_criteria(&mixed, &env));
1074 let open_only = [object("Open"), object("Open")];
1075 assert!(all.meets_criteria(&open_only, &env));
1076 let paid_only = [object("Paid")];
1077 assert!(!one.meets_criteria(&paid_only, &env));
1078 assert!(all.meets_criteria(&[], &env), "with nothing selected, it stops nothing");
1079 assert!(one.meets_criteria(&[], &env));
1080 }
1081
1082 #[test]
1083 fn a_bad_criteria_is_found_when_it_is_checked() {
1084 let bad = Action::simple("a", "a").target_criteria("[Status] =", CriteriaMode::TrueForAll);
1085 assert!(bad.check().is_err_and(|e| e.0.contains("the action a")));
1086 assert!(Action::simple("b", "b").check().is_ok());
1087 }
1088
1089 #[test]
1090 fn addresses_take_values_that_cannot_change_their_meaning() {
1091 let values = BTreeMap::from([
1092 ("q".to_owned(), Value::Text("a b&c=d/ção".to_owned())),
1093 ("n".to_owned(), Value::Number(12.0)),
1094 ]);
1095 let record = Object::from_values("X", values);
1096 assert_eq!(
1097 fill_url("https://x.dev/s?q={q}&n={n}", Some(&record)),
1098 "https://x.dev/s?q=a%20b%26c%3Dd%2F%C3%A7%C3%A3o&n=12"
1099 );
1100 assert_eq!(fill_url("https://x.dev/{none}", Some(&record)), "https://x.dev/");
1101 assert_eq!(fill_url("https://x.dev/{q}", None), "https://x.dev/");
1102 assert_eq!(fill_url("no close {q", Some(&record)), "no close {q");
1103 }
1104
1105 #[test]
1106 fn options_are_found_at_any_depth() {
1107 let action = Action::single_choice(
1108 "Mode",
1109 "Modo",
1110 vec![ChoiceItem::new("a", "A").item(ChoiceItem::new("a1", "A1"))],
1111 );
1112 assert!(action.check_input(&ActionInput::Choice("a1".into())).is_ok());
1113 assert!(action.check_input(&ActionInput::Choice("z".into())).is_err());
1114 assert!(action.check_input(&ActionInput::None).is_err());
1115 }
1116}