Skip to main content

ravenrs_app/
workflow.rs

1//! States a record goes through, and only by the ways declared. It is refused where records are
2//! kept, so it holds with no screen around it.
3
4use ravenrs_criteria::{Criteria, Scope, Value};
5
6use crate::space::key_text;
7use crate::{
8    Action, ActionCategory, CriteriaMode, EntityInfo, Module, Object, Operation, Query, Selection,
9    SpaceError, ViewController,
10};
11
12/// A way from one state to another.
13#[derive(Clone, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
16pub struct Transition {
17    /// The state it leaves.
18    pub from: String,
19    /// The state it reaches.
20    pub to: String,
21    /// What its action says.
22    pub caption: String,
23    /// An expression the record has to meet, as it would be after the change.
24    pub when: Option<String>,
25}
26
27/// The states of a record, in one of its fields, and the ways between them.
28///
29/// ```
30/// use ravenrs_app::{EntityInfo, EnumOption, FieldInfo, FieldKind, StateMachine};
31///
32/// let options = vec![EnumOption::new("draft", "Draft"), EnumOption::new("done", "Done")];
33/// let entity = EntityInfo::new("Task")
34///     .field(FieldInfo::new("id", FieldKind::Integer).key())
35///     .field(FieldInfo::new("state", FieldKind::Enum(options)))
36///     .state_machine(
37///         StateMachine::new("state").state("draft", "Draft").state("done", "Done").start("draft").transition(
38///             "draft",
39///             "done",
40///             "Finish",
41///         ),
42///     );
43/// assert!(entity.check().is_ok());
44/// ```
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46#[non_exhaustive]
47#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
48pub struct StateMachine {
49    /// The field that holds the state: options, text or a reference.
50    pub field: String,
51    /// The states, as stored and as people see them.
52    pub states: Vec<(String, String)>,
53    /// Where a new record has to start; nothing means any state.
54    pub start: Option<String>,
55    /// The ways between states.
56    pub transitions: Vec<Transition>,
57}
58
59#[cfg_attr(feature = "live", ravenrs_live::expose)]
60impl StateMachine {
61    /// Over this field.
62    pub fn new(field: impl Into<String>) -> Self {
63        Self { field: field.into(), ..Self::default() }
64    }
65
66    /// A state, as stored and as people see it.
67    pub fn state(mut self, code: impl Into<String>, caption: impl Into<String>) -> Self {
68        self.states.push((code.into(), caption.into()));
69        self
70    }
71
72    /// Where a new record starts, which also is what it starts with.
73    pub fn start(mut self, code: impl Into<String>) -> Self {
74        self.start = Some(code.into());
75        self
76    }
77
78    /// A way between two states, with what its action says.
79    pub fn transition(
80        mut self,
81        from: impl Into<String>,
82        to: impl Into<String>,
83        caption: impl Into<String>,
84    ) -> Self {
85        self.transitions.push(Transition {
86            from: from.into(),
87            to: to.into(),
88            caption: caption.into(),
89            when: None,
90        });
91        self
92    }
93
94    /// The last way declared only opens for records this expression holds for.
95    pub fn when(mut self, expression: impl Into<String>) -> Self {
96        if let Some(last) = self.transitions.last_mut() {
97            last.when = Some(expression.into());
98        }
99        self
100    }
101
102    fn caption_of<'a>(&'a self, code: &'a str) -> &'a str {
103        self.states.iter().find(|(known, _)| known == code).map_or(code, |(_, caption)| caption)
104    }
105
106    pub(crate) fn problem(&self) -> Option<String> {
107        let known = |code: &str| self.states.iter().any(|(state, _)| state == code);
108        if self.states.is_empty() {
109            return Some(format!("the states of {} are not declared", self.field));
110        }
111        if let Some(start) = self.start.as_deref().filter(|start| !known(start)) {
112            return Some(format!("{} starts in {start}, which is not one of its states", self.field));
113        }
114        self.transitions.iter().find_map(|transition| {
115            [&transition.from, &transition.to]
116                .into_iter()
117                .find(|state| !known(state))
118                .map(|state| format!("{} goes through {state}, which is not one of its states", self.field))
119                .or_else(|| {
120                    let expression = transition.when.as_deref()?;
121                    Criteria::parse(expression).err().map(|error| format!("{}: {error}", self.field))
122                })
123        })
124    }
125
126    /// Whether the record can make this change: to be called with the record as storage has it, or
127    /// nothing if it is new, and as it is about to be.
128    fn check(&self, entity: &EntityInfo, old: Option<&Object>, new: &Object) -> Result<(), SpaceError> {
129        let text = |object: &Object| key_text(&object.get(&self.field)).unwrap_or_default();
130        let to = text(new);
131        let refused = |why: String| Err(SpaceError::Refused(format!("{}: {why}", entity.caption)));
132        let Some(old) = old else {
133            if !self.states.iter().any(|(state, _)| *state == to) {
134                return refused(format!("{to} is not a state"));
135            }
136            return match &self.start {
137                Some(start) if *start != to => {
138                    refused(format!("a new record starts in {}", self.caption_of(start)))
139                }
140                _ => Ok(()),
141            };
142        };
143        let from = text(old);
144        if from == to {
145            return Ok(());
146        }
147        let environment = crate::environment();
148        let mut after = new.clone();
149        entity.compute(&mut after, &environment);
150        let opens = self.transitions.iter().filter(|way| way.from == from && way.to == to).any(|way| {
151            way.when.as_deref().is_none_or(|expression| {
152                // One that does not read, or fails on the record, does not open.
153                Criteria::parse(expression)
154                    .ok()
155                    .is_some_and(|criteria| Scope::new(&environment).row(&after).matches(&criteria))
156            })
157        });
158        if opens {
159            Ok(())
160        } else {
161            refused(format!("it cannot go from {} to {}", self.caption_of(&from), self.caption_of(&to)))
162        }
163    }
164}
165
166/// Whether a change keeps to the state machines of its entity. Whoever keeps records calls this
167/// with what is stored and what is about to be, before writing, as every space here does.
168pub fn check_transitions(entity: &EntityInfo, old: Option<&Object>, new: &Object) -> Result<(), SpaceError> {
169    entity.state_machines.iter().try_for_each(|machine| machine.check(entity, old, new))
170}
171
172/// The field of a record that says when to remind people of it, and what to say.
173#[derive(Clone, Debug, PartialEq, Eq)]
174#[non_exhaustive]
175#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
176pub struct Reminder {
177    /// The date and time it is due; empty means nothing to remind.
178    pub field: String,
179    /// The field that says what it is about; nothing means what names the record.
180    pub message: Option<String>,
181    /// An expression for whom it is, where `?CurrentUserId` and `?CurrentUserName` are the person.
182    pub criteria: Option<String>,
183}
184
185#[cfg_attr(feature = "live", ravenrs_live::expose)]
186impl Reminder {
187    /// Due at the time in this field.
188    pub fn new(field: impl Into<String>) -> Self {
189        Self { field: field.into(), message: None, criteria: None }
190    }
191
192    /// Says what this field holds.
193    pub fn message(mut self, field: impl Into<String>) -> Self {
194        self.message = Some(field.into());
195        self
196    }
197
198    /// Only for records this expression holds for.
199    pub fn criteria(mut self, expression: impl Into<String>) -> Self {
200        self.criteria = Some(expression.into());
201        self
202    }
203}
204
205/// The date and time on this machine, as people read it on a clock.
206pub fn local_now() -> ravenrs_criteria::DateTime {
207    #[cfg(target_arch = "wasm32")]
208    {
209        ravenrs_criteria::DateTime::now()
210    }
211    #[cfg(not(target_arch = "wasm32"))]
212    {
213        let utc = ravenrs_criteria::DateTime::now();
214        let offset = i64::from(jiff::tz::TimeZone::system().to_offset(jiff::Timestamp::now()).seconds());
215        let seconds = i64::from(utc.seconds) + offset;
216        ravenrs_criteria::DateTime {
217            days: utc.days + seconds.div_euclid(86_400),
218            seconds: seconds.rem_euclid(86_400) as u32,
219        }
220    }
221}
222
223struct Transitions {
224    actions: Vec<Action>,
225}
226
227impl ViewController for Transitions {
228    fn id(&self) -> &str {
229        "StateTransitions"
230    }
231
232    fn actions(&self) -> Vec<Action> {
233        self.actions.clone()
234    }
235}
236
237fn action_of(entity: &EntityInfo, machine: &StateMachine, way: &Transition) -> Action {
238    let (field, to) = (machine.field.clone(), way.to.clone());
239    let key = entity.key_field().map(|key| key.name.clone()).unwrap_or_default();
240    let name = entity.name.clone();
241    let id = format!("{}.{}.{}.{}", entity.name, machine.field, way.from, way.to);
242    let mut opens = format!("[{field}] = \'{}\'", way.from.replace('\'', "\'\'"));
243    if let Some(when) = &way.when {
244        opens = format!("({opens}) And ({when})");
245    }
246    Action::simple(id, way.caption.clone())
247        .category(ActionCategory::Edit)
248        .target_entity(entity.name.clone())
249        .selection(Selection::RequireMultiple)
250        .target_criteria(opens, CriteriaMode::TrueForAll)
251        .requires(Operation::Write)
252        .on_execute(move |cx| {
253            let mut space = cx.space.borrow_mut();
254            let result = (|| {
255                for record in &cx.selected {
256                    let mut stored = space
257                        .find(&name, &record.get(&key))?
258                        .ok_or_else(|| SpaceError::Conflict(format!("a record of {name} is gone")))?;
259                    stored.set(&field, Value::Text(to.clone()));
260                    space.save(stored)?;
261                }
262                space.commit()
263            })();
264            if let Err(error) = result {
265                space.rollback();
266                return Err(error.to_string());
267            }
268            drop(space);
269            cx.records_changed();
270            Ok(())
271        })
272}
273
274/// What gives the entities with state machines their actions. It comes by itself.
275#[derive(Debug)]
276pub(crate) struct StateMachineModule(pub(crate) Vec<EntityInfo>);
277
278impl Module for StateMachineModule {
279    fn name(&self) -> &str {
280        "StateMachines"
281    }
282
283    fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
284        let actions = self
285            .0
286            .iter()
287            .flat_map(|entity| {
288                entity.state_machines.iter().flat_map(move |machine| {
289                    machine.transitions.iter().map(move |way| action_of(entity, machine, way))
290                })
291            })
292            .collect();
293        vec![Box::new(Transitions { actions })]
294    }
295}
296
297/// The records of an entity with a reminder that have a time set, for this person, as this space
298/// shows them. What is due by now is up to whoever asks, as they know the time.
299pub fn reminders_of(
300    space: &dyn crate::ObjectSpace,
301    entity: &EntityInfo,
302    environment: &ravenrs_criteria::Environment,
303) -> Vec<Object> {
304    let Some(reminder) = &entity.reminder else { return Vec::new() };
305    let found = space.query(&entity.name, &Query::all()).unwrap_or_default();
306    let criteria = reminder.criteria.as_deref().and_then(|text| Criteria::parse(text).ok());
307    found
308        .into_iter()
309        .filter(|record| matches!(record.get(&reminder.field), Value::Date(_)))
310        .filter(|record| {
311            criteria.as_ref().is_none_or(|criteria| Scope::new(environment).row(record).matches(criteria))
312        })
313        .collect()
314}