Skip to main content

tmprl_core/
form.rs

1//! A small multi-field editor, for the things that need more than one value.
2//!
3//! Every other input in tmprl is one value, and the one-line prompt covers those. Creating a
4//! schedule needs six, which as a positional line would be unreadable to type and silent to
5//! mistype: a task queue and a cron string are both strings, so a swapped pair only surfaces
6//! as a server error much later.
7//!
8//! Editing lives here rather than in the renderer so that field movement, validation and the
9//! rendered command are all testable without a terminal.
10
11/// One labelled line of a [`Form`].
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Field {
14    pub label: &'static str,
15    pub value: String,
16    /// Shown in place of an empty value, to say what belongs there.
17    pub hint: &'static str,
18    pub required: bool,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Form {
23    pub title: &'static str,
24    pub fields: Vec<Field>,
25    /// Which field the caret is on.
26    pub cursor: usize,
27}
28
29impl Form {
30    /// The fields `temporal schedule create` needs.
31    ///
32    /// `workflow id` is the id given to the workflows the schedule starts, not the schedule's
33    /// own id; the server appends the scheduled time to it, so the two are different things
34    /// and naming both avoids the guess.
35    pub fn new_schedule() -> Self {
36        let f = |label, hint, required| Field {
37            label,
38            value: String::new(),
39            hint,
40            required,
41        };
42        Form {
43            title: "new schedule",
44            fields: vec![
45                f("schedule id", "nightly-recon", true),
46                f("workflow id", "recon", true),
47                f("workflow type", "OrderWorkflow", true),
48                f("task queue", "demo-tq", true),
49                f("spec", "0 2 * * *  or  @every 1h", true),
50                f("input", "optional JSON", false),
51            ],
52            cursor: 0,
53        }
54    }
55
56    /// Move down a field, wrapping. Tab in a form goes forward and stops nowhere.
57    pub fn next(&mut self) {
58        if !self.fields.is_empty() {
59            self.cursor = (self.cursor + 1) % self.fields.len();
60        }
61    }
62
63    pub fn previous(&mut self) {
64        if !self.fields.is_empty() {
65            self.cursor = (self.cursor + self.fields.len() - 1) % self.fields.len();
66        }
67    }
68
69    pub fn push(&mut self, c: char) {
70        if let Some(f) = self.fields.get_mut(self.cursor) {
71            f.value.push(c);
72        }
73    }
74
75    /// Delete backwards. Reports whether anything was there, so the caller can decide what an
76    /// empty backspace means.
77    pub fn backspace(&mut self) -> bool {
78        self.fields
79            .get_mut(self.cursor)
80            .and_then(|f| f.value.pop())
81            .is_some()
82    }
83
84    /// The value of a field by label, trimmed. Empty when absent.
85    pub fn get(&self, label: &str) -> &str {
86        self.fields
87            .iter()
88            .find(|f| f.label == label)
89            .map(|f| f.value.trim())
90            .unwrap_or("")
91    }
92
93    /// The first required field still empty, if any.
94    ///
95    /// Checked before the confirmation rather than after, so the reader is sent back to the
96    /// field that is missing instead of reading a command with a hole in it.
97    pub fn missing(&self) -> Option<&'static str> {
98        self.fields
99            .iter()
100            .find(|f| f.required && f.value.trim().is_empty())
101            .map(|f| f.label)
102    }
103
104    /// Put the caret on a named field, for jumping to the one that failed validation.
105    pub fn focus(&mut self, label: &str) {
106        if let Some(i) = self.fields.iter().position(|f| f.label == label) {
107            self.cursor = i;
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn a_new_schedule_asks_for_everything_the_server_requires() {
118        // schedule-id, workflow-id, task-queue and type are all required by
119        // `temporal schedule create`, and a spec with none of cron/interval/calendar
120        // creates a schedule that never fires.
121        let f = Form::new_schedule();
122        let required: Vec<&str> = f
123            .fields
124            .iter()
125            .filter(|f| f.required)
126            .map(|f| f.label)
127            .collect();
128        assert_eq!(
129            required,
130            [
131                "schedule id",
132                "workflow id",
133                "workflow type",
134                "task queue",
135                "spec"
136            ]
137        );
138    }
139
140    #[test]
141    fn typing_lands_on_the_focused_field_only() {
142        let mut f = Form::new_schedule();
143        for c in "nightly".chars() {
144            f.push(c);
145        }
146        f.next();
147        for c in "recon".chars() {
148            f.push(c);
149        }
150        assert_eq!(f.get("schedule id"), "nightly");
151        assert_eq!(f.get("workflow id"), "recon");
152    }
153
154    #[test]
155    fn the_caret_wraps_in_both_directions() {
156        let mut f = Form::new_schedule();
157        let last = f.fields.len() - 1;
158        f.previous();
159        assert_eq!(f.cursor, last, "back from the first goes to the last");
160        f.next();
161        assert_eq!(f.cursor, 0);
162    }
163
164    #[test]
165    fn backspace_says_whether_it_removed_anything() {
166        let mut f = Form::new_schedule();
167        assert!(!f.backspace(), "nothing to delete on an empty field");
168        f.push('x');
169        assert!(f.backspace());
170        assert_eq!(f.get("schedule id"), "");
171    }
172
173    #[test]
174    fn a_missing_required_field_is_named_rather_than_merely_counted() {
175        // The reader is sent back to the field, so the answer has to be which one.
176        let mut f = Form::new_schedule();
177        assert_eq!(f.missing(), Some("schedule id"));
178        for (label, v) in [
179            ("schedule id", "nightly"),
180            ("workflow id", "recon"),
181            ("workflow type", "OrderWorkflow"),
182            ("task queue", "demo-tq"),
183        ] {
184            f.focus(label);
185            for c in v.chars() {
186                f.push(c);
187            }
188        }
189        assert_eq!(f.missing(), Some("spec"));
190        f.focus("spec");
191        for c in "0 2 * * *".chars() {
192            f.push(c);
193        }
194        assert_eq!(f.missing(), None, "input is optional");
195    }
196
197    #[test]
198    fn whitespace_alone_does_not_satisfy_a_required_field() {
199        let mut f = Form::new_schedule();
200        f.push(' ');
201        assert_eq!(f.missing(), Some("schedule id"));
202    }
203}