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