Skip to main content

polyspec_crudui_generator/
actions.rs

1//! The action buttons of a form: resolving the target of a button and running it on the form (CURT-081 to CURT-090).
2
3use crate::error::{FormError, FormResult};
4use crate::instance::{AddRowOptions, Form};
5
6/// The eleven operations that an action button can request (CURT-081).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ActionName {
9    /// Move the row one position up.
10    MoveUp,
11    /// Move the row one position down.
12    MoveDown,
13    /// Insert a row, after the row key when the button declares one.
14    AddRow,
15    /// Copy the row after itself.
16    CopyRow,
17    /// Remove the row.
18    RemoveRow,
19    /// Expand or collapse the row.
20    ToggleRow,
21    /// Focus the row without changing the form.
22    SelectRow,
23    /// Expand every row.
24    ExpandAll,
25    /// Collapse every collapsible row.
26    CollapseAll,
27    /// Restore the record before the last change.
28    Undo,
29    /// Reapply the most recently undone change.
30    Redo,
31}
32
33impl ActionName {
34    /// The name that a button declares in `data-crudui-action`.
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::MoveUp => "move-up",
38            Self::MoveDown => "move-down",
39            Self::AddRow => "add-row",
40            Self::CopyRow => "copy-row",
41            Self::RemoveRow => "remove-row",
42            Self::ToggleRow => "toggle-row",
43            Self::SelectRow => "select-row",
44            Self::ExpandAll => "expand-all",
45            Self::CollapseAll => "collapse-all",
46            Self::Undo => "undo",
47            Self::Redo => "redo",
48        }
49    }
50
51    /// The operation that a button names with its `data-crudui-action` value, or `None` for any other name.
52    pub fn parse(name: &str) -> Option<Self> {
53        Some(match name {
54            "move-up" => Self::MoveUp,
55            "move-down" => Self::MoveDown,
56            "add-row" => Self::AddRow,
57            "copy-row" => Self::CopyRow,
58            "remove-row" => Self::RemoveRow,
59            "toggle-row" => Self::ToggleRow,
60            "select-row" => Self::SelectRow,
61            "expand-all" => Self::ExpandAll,
62            "collapse-all" => Self::CollapseAll,
63            "undo" => Self::Undo,
64            "redo" => Self::Redo,
65            _ => return None,
66        })
67    }
68}
69
70/// The values a button declares: its action, collection path and row key (CURT-081). An absent or empty member is
71/// absent from the target.
72#[derive(Debug, Default, Clone, serde::Deserialize)]
73pub struct ActionInput {
74    /// Requested operation name.
75    pub action: Option<String>,
76    /// Collection path of the nearest `[data-field-path]`.
77    pub path: Option<String>,
78    /// Row key of the nearest `[data-crudui-row-key]` inside that path element.
79    pub key: Option<String>,
80}
81
82/// The operation, collection path and row key that an action button targets.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ActionTarget {
85    /// Requested operation.
86    pub name: ActionName,
87    /// Collection path; `None` when the button declares none.
88    pub path: Option<String>,
89    /// Row key; `None` when the button declares none.
90    pub key: Option<String>,
91}
92
93impl ActionTarget {
94    /// The target that a button names by `name`; a name that is none of the eleven actions fails and changes
95    /// nothing when it runs (CURT-091).
96    pub fn named(name: &str, path: Option<String>, key: Option<String>) -> FormResult<Self> {
97        let Some(name) = ActionName::parse(name) else {
98            return Err(FormError::input(format!("Unknown action: {name}")));
99        };
100        Ok(Self { name, path, key })
101    }
102}
103
104/// The row that receives focus after an action, or the collection itself when `key` is absent.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct FocusTarget {
107    /// Collection path.
108    pub path: String,
109    /// Row key; absent when the collection has no rows left.
110    pub key: Option<String>,
111}
112
113/// The outcome of an action that ran.
114#[derive(Debug, Default, Clone, PartialEq, Eq)]
115pub struct ActionResult {
116    /// Focus destination; absent when focus stays on the current control.
117    pub focus: Option<FocusTarget>,
118}
119
120/// Resolve the target of the values a button declares; `None` when the action is none of the eleven actions.
121pub fn resolve_action(input: &ActionInput) -> Option<ActionTarget> {
122    let name = ActionName::parse(input.action.as_deref()?)?;
123    Some(ActionTarget {
124        name,
125        path: non_empty(&input.path),
126        key: non_empty(&input.key),
127    })
128}
129
130/// Run a resolved action on the form. `None` when the target lacks the path or key that the action needs; a failing
131/// operation returns its error and leaves the form unchanged (CURT-090).
132pub fn run_action(form: &mut Form, target: &ActionTarget) -> FormResult<Option<ActionResult>> {
133    match target.name {
134        ActionName::ExpandAll | ActionName::CollapseAll => {
135            form.set_all_expanded(target.name == ActionName::ExpandAll)?;
136            return Ok(Some(ActionResult::default()));
137        }
138        ActionName::Undo => {
139            form.undo()?;
140            return Ok(Some(ActionResult::default()));
141        }
142        ActionName::Redo => {
143            form.redo()?;
144            return Ok(Some(ActionResult::default()));
145        }
146        _ => {}
147    }
148    let Some(path) = target.path.as_deref().filter(|path| !path.is_empty()) else {
149        return Ok(None);
150    };
151    if target.name == ActionName::AddRow {
152        // Without a declared key the row is appended; with one it is inserted after that row.
153        let options = AddRowOptions {
154            after_key: target.key.clone(),
155            ..AddRowOptions::default()
156        };
157        let key = form.add_row(path, options)?;
158        return Ok(Some(focus_on(path, &key)));
159    }
160    let Some(key) = target.key.as_deref().filter(|key| !key.is_empty()) else {
161        return Ok(None);
162    };
163    let result = match target.name {
164        ActionName::CopyRow => {
165            let copied = form.copy_row(path, key, AddRowOptions::default())?;
166            focus_on(path, &copied)
167        }
168        ActionName::RemoveRow => {
169            let keys = row_keys(form, path)?;
170            let index = keys.iter().position(|known| known == key);
171            // Focus goes to the previous row, else the next row, else the collection.
172            let neighbour = index.and_then(|index| {
173                if index > 0 {
174                    keys.get(index - 1)
175                } else {
176                    keys.get(index + 1)
177                }
178            });
179            form.remove_row(path, key)?;
180            match neighbour {
181                Some(neighbour) => focus_on(path, neighbour),
182                None => ActionResult {
183                    focus: Some(FocusTarget {
184                        path: path.to_owned(),
185                        key: None,
186                    }),
187                },
188            }
189        }
190        ActionName::ToggleRow => {
191            form.toggle_row(path, key)?;
192            ActionResult::default()
193        }
194        // Selecting changes no state: it only moves focus to the row.
195        ActionName::SelectRow => focus_on(path, key),
196        ActionName::MoveUp | ActionName::MoveDown => {
197            let keys = row_keys(form, path)?;
198            // A key that the collection lacks gives no position; the move then fails with the unknown row.
199            let from = keys
200                .iter()
201                .position(|known| known == key)
202                .map_or(-1, |index| index as i64);
203            let step = if target.name == ActionName::MoveUp {
204                -1
205            } else {
206                1
207            };
208            form.move_row(path, key, from + step)?;
209            focus_on(path, key)
210        }
211        ActionName::AddRow => unreachable!("add-row returns before the key is read"),
212        ActionName::ExpandAll | ActionName::CollapseAll | ActionName::Undo | ActionName::Redo => {
213            unreachable!("the form-wide actions return before the path is read")
214        }
215    };
216    Ok(Some(result))
217}
218
219/// The value of an optional member, where an empty string counts as absent.
220fn non_empty(value: &Option<String>) -> Option<String> {
221    value
222        .as_deref()
223        .filter(|text| !text.is_empty())
224        .map(str::to_owned)
225}
226
227/// The result of an action that focuses the row `key` of the collection at `path`.
228fn focus_on(path: &str, key: &str) -> ActionResult {
229    ActionResult {
230        focus: Some(FocusTarget {
231            path: path.to_owned(),
232            key: Some(key.to_owned()),
233        }),
234    }
235}
236
237/// The row keys of the keyed collection at `path`, in order.
238fn row_keys(form: &Form, path: &str) -> FormResult<Vec<String>> {
239    match form.get_value(path)? {
240        serde_json::Value::Object(rows) => Ok(rows.keys().cloned().collect()),
241        _ => Err(FormError::input(format!(
242            "Repeated data must be a keyed object: {path}"
243        ))),
244    }
245}