Skip to main content

ravenrs_app_ui/
system.rs

1//! What every application needs: creating, saving, removing and moving between records.
2
3use ravenrs_app::{
4    Action, ActionCategory, ActionInput, Criteria, Frame, Module, Nesting, Operation, Selection, TargetScope,
5    Value, ViewController, ViewKind,
6};
7
8use crate::nested::NestedOwner;
9
10use crate::session::{Session, Words};
11
12/// The ids of the actions of [`SystemModule`].
13pub mod ids {
14    /// Creates a record.
15    pub const NEW: &str = "New";
16    /// Removes what is selected.
17    pub const DELETE: &str = "Delete";
18    /// Keeps the changes.
19    pub const SAVE: &str = "Save";
20    /// Keeps the changes and leaves.
21    pub const SAVE_AND_CLOSE: &str = "SaveAndClose";
22    /// Keeps the changes and starts another.
23    pub const SAVE_AND_NEW: &str = "SaveAndNew";
24    /// Throws away the changes and leaves.
25    pub const CANCEL: &str = "Cancel";
26    /// The record before.
27    pub const PREVIOUS: &str = "PreviousObject";
28    /// The record after.
29    pub const NEXT: &str = "NextObject";
30    /// Reads again.
31    pub const REFRESH: &str = "Refresh";
32    /// Goes to the screen before.
33    pub const BACK: &str = "Back";
34    /// Picks a way to see the list.
35    pub const VARIANTS: &str = "ViewVariants";
36    /// Picks a filter ready on the list.
37    pub const FILTERS: &str = "SetFilter";
38    /// Makes a new record from the one selected.
39    pub const CLONE: &str = "CloneObject";
40    /// Puts an existing record on a list inside a form.
41    pub const LINK: &str = "LinkObject";
42    /// Takes records off a list inside a form, without removing them.
43    pub const UNLINK: &str = "UnlinkObject";
44}
45
46/// The actions every application needs. Searching, filtering and exporting a list come with
47/// [`DataGrid`](ravenrs_widgets::DataGrid), and are not repeated here.
48#[derive(Debug, Default)]
49#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
50pub struct SystemModule {
51    words: Words,
52}
53
54#[cfg_attr(feature = "live", ravenrs_live::expose)]
55impl SystemModule {
56    /// With its words in English.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// With these words.
62    pub fn with_words(words: Words) -> Self {
63        Self { words }
64    }
65}
66
67fn session_of(cx: &ravenrs_app::ActionContext) -> Result<Session, String> {
68    Session::of(cx).ok_or_else(|| "this action needs the application's screens".to_owned())
69}
70
71fn entity_of(cx: &ravenrs_app::ActionContext) -> Result<String, String> {
72    cx.view.as_ref().and_then(|view| view.entity.clone()).ok_or_else(|| "this view has no entity".to_owned())
73}
74
75/// A dashboard has no records of its own to act on.
76fn off_on_a_dashboard(frame: &Frame, ids: &[&str]) {
77    let on = frame.view().is_none_or(|view| view.kind != ViewKind::Dashboard);
78    for id in ids {
79        if let Some(action) = frame.action(id) {
80            action.set_active("dashboard", on);
81        }
82    }
83}
84
85struct NewObjectController(Words);
86
87impl ViewController for NewObjectController {
88    fn id(&self) -> &str {
89        "NewObject"
90    }
91    fn scope(&self) -> TargetScope {
92        TargetScope::any().nesting(Nesting::Root)
93    }
94    fn activated(&mut self, frame: &Frame) {
95        off_on_a_dashboard(frame, &[ids::NEW]);
96    }
97    fn actions(&self) -> Vec<Action> {
98        vec![
99            Action::simple(ids::NEW, self.0.new.clone())
100                .category(ActionCategory::New)
101                .requires(Operation::Create)
102                .icon("plus")
103                .on_execute(|cx| {
104                    let (session, entity) = (session_of(cx)?, entity_of(cx)?);
105                    let opener = session.clone();
106                    session.leaving(move || opener.open_new(&entity));
107                    Ok(())
108                }),
109        ]
110    }
111}
112
113struct DeleteController(Words);
114
115impl ViewController for DeleteController {
116    fn id(&self) -> &str {
117        "DeleteObjects"
118    }
119    fn activated(&mut self, frame: &Frame) {
120        off_on_a_dashboard(frame, &[ids::DELETE]);
121    }
122    fn actions(&self) -> Vec<Action> {
123        vec![
124            Action::simple(ids::DELETE, self.0.delete.clone())
125                .category(ActionCategory::Edit)
126                .requires(Operation::Delete)
127                .icon("trash-2")
128                .selection(Selection::RequireMultiple)
129                .confirmation(self.0.delete_question.clone())
130                .on_execute(|cx| session_of(cx)?.delete(&cx.selected)),
131        ]
132    }
133}
134
135struct ModificationsController(Words);
136
137impl ViewController for ModificationsController {
138    fn id(&self) -> &str {
139        "Modifications"
140    }
141    fn scope(&self) -> TargetScope {
142        TargetScope::any().view_kind(ViewKind::Detail)
143    }
144    fn actions(&self) -> Vec<Action> {
145        vec![
146            Action::simple(ids::SAVE, self.0.save.clone())
147                .category(ActionCategory::Save)
148                .requires(Operation::Write)
149                .icon("save")
150                .hotkey("command+s")
151                .on_execute(|cx| session_of(cx)?.save()),
152            Action::simple(ids::SAVE_AND_CLOSE, self.0.save_and_close.clone())
153                .category(ActionCategory::Save)
154                .requires(Operation::Write)
155                .hotkey("command+shift+s")
156                .on_execute(|cx| session_of(cx)?.save_then(Session::back_now)),
157            Action::simple(ids::SAVE_AND_NEW, self.0.save_and_new.clone())
158                .category(ActionCategory::Save)
159                .requires(Operation::Write)
160                .on_execute(|cx| {
161                    let (session, entity) = (session_of(cx)?, entity_of(cx)?);
162                    session.save_then(move |session| session.open_new(&entity))
163                }),
164            Action::simple(ids::CANCEL, self.0.cancel.clone()).category(ActionCategory::Save).on_execute(
165                |cx| {
166                    session_of(cx)?.cancel();
167                    Ok(())
168                },
169            ),
170        ]
171    }
172}
173
174struct RecordsNavigationController(Words);
175
176impl ViewController for RecordsNavigationController {
177    fn id(&self) -> &str {
178        "RecordsNavigation"
179    }
180    fn scope(&self) -> TargetScope {
181        TargetScope::any().view_kind(ViewKind::Detail)
182    }
183    fn actions(&self) -> Vec<Action> {
184        vec![
185            Action::simple(ids::BACK, self.0.back.clone())
186                .category(ActionCategory::Navigation)
187                .icon("arrow-left")
188                .on_execute(|cx| {
189                    session_of(cx)?.back();
190                    Ok(())
191                }),
192            Action::simple(ids::PREVIOUS, self.0.previous.clone())
193                .category(ActionCategory::Navigation)
194                .icon("chevron-left")
195                .on_execute(|cx| {
196                    session_of(cx)?.step(-1);
197                    Ok(())
198                }),
199            Action::simple(ids::NEXT, self.0.next.clone())
200                .category(ActionCategory::Navigation)
201                .icon("chevron-right")
202                .on_execute(|cx| {
203                    session_of(cx)?.step(1);
204                    Ok(())
205                }),
206        ]
207    }
208}
209
210struct RefreshController(Words);
211
212impl ViewController for RefreshController {
213    fn id(&self) -> &str {
214        "Refresh"
215    }
216    fn scope(&self) -> TargetScope {
217        TargetScope::any().nesting(ravenrs_app::Nesting::Root)
218    }
219    fn actions(&self) -> Vec<Action> {
220        vec![
221            Action::simple(ids::REFRESH, self.0.refresh.clone())
222                .category(ActionCategory::View)
223                .icon("refresh-cw")
224                .on_execute(|cx| {
225                    session_of(cx)?.refresh();
226                    Ok(())
227                }),
228        ]
229    }
230}
231
232/// A menu of what the model offers a list: only where there is a choice to make.
233struct ChoicesController {
234    id: &'static str,
235    action: &'static str,
236    part: &'static str,
237    caption: String,
238    fewest: usize,
239}
240
241impl ViewController for ChoicesController {
242    fn id(&self) -> &str {
243        self.id
244    }
245    fn actions(&self) -> Vec<Action> {
246        let part = self.part;
247        vec![
248            Action::single_choice(self.action, self.caption.clone(), Vec::new())
249                .category(ActionCategory::View)
250                .on_execute(move |cx| {
251                    let session = session_of(cx)?;
252                    let view = cx.view.as_ref().ok_or_else(|| "no view".to_owned())?;
253                    match &cx.input {
254                        ActionInput::Choice(choice) => session.choose(&view.id, part, choice),
255                        _ => Ok(()),
256                    }
257                }),
258        ]
259    }
260    fn activated(&mut self, frame: &Frame) {
261        let enough = frame.view().zip(frame.model()).is_some_and(|(view, model)| {
262            model.at(&["Views", &view.id, self.part]).is_some_and(|all| all.children.len() >= self.fewest)
263        });
264        if let Some(action) = frame.action(self.action) {
265            action.set_active("choices", enough);
266        }
267    }
268}
269
270struct LinkController(Words);
271
272/// Changes what a record points to, keeps that, and shows it.
273fn point(
274    cx: &ravenrs_app::ActionContext,
275    record: &ravenrs_app::Object,
276    field: &str,
277    to: Value,
278) -> Result<(), String> {
279    let mut record = record.clone();
280    record.set(field, to);
281    cx.space.borrow_mut().save(record).map_err(|e| e.to_string())
282}
283
284fn keep(cx: &ravenrs_app::ActionContext) -> Result<(), String> {
285    let kept = cx.space.borrow_mut().commit().map_err(|e| e.to_string());
286    if kept.is_err() {
287        cx.space.borrow_mut().rollback();
288    }
289    kept?;
290    session_of(cx)?.touch();
291    Ok(())
292}
293
294impl ViewController for LinkController {
295    fn id(&self) -> &str {
296        "LinkObjects"
297    }
298    fn scope(&self) -> TargetScope {
299        TargetScope::any().view_kind(ViewKind::List).nesting(Nesting::Nested)
300    }
301    fn actions(&self) -> Vec<Action> {
302        vec![
303            Action::simple(ids::LINK, self.0.link.clone())
304                .category(ActionCategory::Edit)
305                .icon("link")
306                .on_execute(|cx| {
307                    let owner = cx.frame.extension::<NestedOwner>().ok_or_else(|| "no list".to_owned())?;
308                    let ActionInput::Parameter(key) = &cx.input else {
309                        // Asked for the first time: it opens the list to pick from, and OK comes back here.
310                        // What already points here is not there to pick.
311                        let free = Criteria::parse(&format!("IsNull([{}])", owner.field)).ok().map(|empty| {
312                            let elsewhere = Criteria::parse(&format!(
313                                "[{}] <> {}",
314                                owner.field,
315                                crate::editors::literal_of(&owner.key)
316                            ));
317                            elsewhere.map_or(empty.clone(), |elsewhere| empty.or(elsewhere))
318                        });
319                        let filter = match (free, owner.link_filter.clone()) {
320                            (Some(free), Some(own)) => Some(free.and(own)),
321                            (free, own) => free.or(own),
322                        };
323                        session_of(cx)?.set_popup_filter(filter);
324                        cx.frame.open_popup_on(ids::LINK, &format!("{}_LookupListView", owner.target));
325                        return Ok(());
326                    };
327                    let found = cx.space.borrow().find(&owner.target, key).map_err(|e| e.to_string())?;
328                    let Some(record) = found else { return Ok(()) };
329                    point(cx, &record, &owner.field, owner.key.clone())?;
330                    keep(cx)
331                }),
332            Action::simple(ids::UNLINK, self.0.unlink.clone())
333                .category(ActionCategory::Edit)
334                .icon("unlink")
335                .selection(Selection::RequireMultiple)
336                .on_execute(|cx| {
337                    let owner = cx.frame.extension::<NestedOwner>().ok_or_else(|| "no list".to_owned())?;
338                    for record in &cx.selected {
339                        point(cx, record, &owner.field, Value::Null)?;
340                    }
341                    keep(cx)
342                }),
343        ]
344    }
345}
346
347impl Module for SystemModule {
348    fn name(&self) -> &str {
349        "System"
350    }
351
352    fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
353        let words = || self.words.clone();
354        vec![
355            Box::new(RecordsNavigationController(words())),
356            Box::new(NewObjectController(words())),
357            Box::new(ModificationsController(words())),
358            Box::new(DeleteController(words())),
359            Box::new(RefreshController(words())),
360            Box::new(ChoicesController {
361                id: "ViewVariants",
362                action: ids::VARIANTS,
363                part: "Variants",
364                caption: self.words.variant.clone(),
365                // One way to see a list is no choice at all.
366                fewest: 2,
367            }),
368            Box::new(ChoicesController {
369                id: "ListFilters",
370                action: ids::FILTERS,
371                part: "Filters",
372                caption: self.words.filter.clone(),
373                fewest: 1,
374            }),
375            Box::new(LinkController(words())),
376        ]
377    }
378}
379
380struct CloneController(Words);
381
382impl ViewController for CloneController {
383    fn id(&self) -> &str {
384        "CloneObject"
385    }
386    fn scope(&self) -> TargetScope {
387        TargetScope::any().nesting(Nesting::Root)
388    }
389    fn actions(&self) -> Vec<Action> {
390        let unsaved = self.0.clone_unsaved.clone();
391        vec![
392            Action::simple(ids::CLONE, self.0.clone.clone())
393                .category(ActionCategory::Edit)
394                .icon("copy")
395                .selection(Selection::RequireSingle)
396                .on_execute(move |cx| {
397                    let session = session_of(cx)?;
398                    // The clone goes in a unit of work of its own, which would not see them.
399                    if session.has_unsaved_changes() {
400                        return Err(unsaved.clone());
401                    }
402                    let source = cx.selected.first().ok_or_else(|| "nothing selected".to_owned())?;
403                    session.clone_record(source)
404                }),
405        ]
406    }
407    fn activated(&mut self, frame: &Frame) {
408        let cloneable = frame.view().and_then(|view| view.entity).is_some_and(|name| {
409            Session::of_frame(frame).and_then(|session| session.entity(&name)).is_some_and(|e| e.cloneable)
410        });
411        if let Some(action) = frame.action(ids::CLONE) {
412            action.set_active("cloneable", cloneable);
413        }
414    }
415}
416
417/// Clones what its entity says can be: what a record holds comes along, a reference points to the
418/// same record, and only a list marked as belonging to the record is cloned with it. Everything
419/// else is left out, so it does not copy a whole graph by accident.
420#[derive(Debug, Default)]
421#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
422pub struct CloneModule {
423    words: Words,
424}
425
426#[cfg_attr(feature = "live", ravenrs_live::expose)]
427impl CloneModule {
428    /// With these words.
429    pub fn with_words(words: Words) -> Self {
430        Self { words }
431    }
432}
433
434impl Module for CloneModule {
435    fn name(&self) -> &str {
436        "CloneObject"
437    }
438
439    fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
440        vec![Box::new(CloneController(self.words.clone()))]
441    }
442}