1use cli_core::render::schema::{self, EntitySchema};
4use cli_core::render::{ListRow, RenderError, WireValue, parse_wire, reparse_list};
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
9
10use crate::tabs::fetch::Fetch;
11
12pub struct ListTab {
13 pub request_form: String,
14 schema: &'static EntitySchema,
15 pub state: Fetch<Vec<ListRow>>,
16 pub selected: usize,
17 pub scroll: usize,
18}
19
20impl ListTab {
21 pub fn new(request_form: impl Into<String>, schema: &'static EntitySchema) -> Self {
22 Self {
23 request_form: request_form.into(),
24 schema,
25 state: Fetch::Idle,
26 selected: 0,
27 scroll: 0,
28 }
29 }
30
31 pub fn set_loading_id(&mut self, id: i64) {
33 self.state = Fetch::Loading { id };
34 }
35
36 pub fn on_reply(&mut self, wire: &str) {
38 self.state = parse_reply(wire, self.schema);
39 }
40
41 pub fn selected_id(&self) -> Option<&str> {
43 let Fetch::Loaded(ref rows) = self.state else {
44 return None;
45 };
46 rows.get(self.selected).and_then(|r| r.id.as_deref())
47 }
48
49 pub fn select_next(&mut self) {
50 if let Fetch::Loaded(ref rows) = self.state
51 && self.selected + 1 < rows.len()
52 {
53 self.selected += 1;
54 }
55 }
56
57 pub fn select_prev(&mut self) {
58 self.selected = self.selected.saturating_sub(1);
59 }
60
61 pub fn reset(&mut self) {
63 self.state = Fetch::Idle;
64 self.selected = 0;
65 self.scroll = 0;
66 }
67
68 pub fn draw(&self, frame: &mut Frame, area: Rect, title: &str) {
70 let block = Block::default().borders(Borders::ALL).title(title);
71 match &self.state {
72 Fetch::Idle => {
73 frame.render_widget(Paragraph::new("Waiting...").block(block), area);
74 }
75 Fetch::Loading { .. } => {
76 frame.render_widget(Paragraph::new("Loading...").block(block), area);
77 }
78 Fetch::Error(e) => {
79 let msg = format!("[error] {e}");
80 frame.render_widget(Paragraph::new(msg).block(block), area);
81 }
82 Fetch::Loaded(rows) => {
83 let hdrs = schema::headers(self.schema);
84 draw_table(frame, area, block, &hdrs, rows, self.selected);
85 }
86 }
87 }
88}
89
90fn parse_reply(wire: &str, entity_schema: &EntitySchema) -> Fetch<Vec<ListRow>> {
91 match parse_wire(wire) {
92 Ok(WireValue::Value(scripting::nomiscript::Value::String(ref s))) => {
93 match reparse_list(s) {
94 Ok(v) => Fetch::Loaded(schema::project_rows(&v, entity_schema)),
95 Err(RenderError::ListReparse(msg)) => Fetch::Error(msg),
96 Err(e) => Fetch::Error(e.to_string()),
97 }
98 }
99 Ok(WireValue::Value(ref v)) => Fetch::Loaded(schema::project_rows(v, entity_schema)),
100 Err(RenderError::Server { code, message }) => Fetch::Error(format!("[{code}] {message}")),
101 Err(e) => Fetch::Error(e.to_string()),
102 }
103}
104
105fn draw_table(
106 frame: &mut Frame,
107 area: Rect,
108 block: Block,
109 headers: &[&str],
110 rows: &[ListRow],
111 selected: usize,
112) {
113 let header_cells: Vec<Cell> = headers
114 .iter()
115 .map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)))
116 .collect();
117 let header = Row::new(header_cells).style(Style::default().fg(Color::Yellow));
118 let data_rows: Vec<Row> = rows
119 .iter()
120 .map(|row| {
121 Row::new(
122 row.cells
123 .iter()
124 .map(|c| Cell::from(c.clone()))
125 .collect::<Vec<_>>(),
126 )
127 })
128 .collect();
129 let col_count = headers.len().max(1);
130 let widths: Vec<Constraint> = (0..col_count)
131 .map(|_| Constraint::Ratio(1, col_count as u32))
132 .collect();
133 let table = Table::new(data_rows, widths)
134 .header(header)
135 .block(block)
136 .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
137 let mut state = TableState::default();
138 state.select(Some(selected));
139 frame.render_stateful_widget(table, area, &mut state);
140}
141
142#[cfg(test)]
143mod tests {
144 use cli_core::render::schema;
145
146 use super::*;
147
148 fn wire_value(id: u64, val: &str) -> String {
149 format!("(:id {id} :value {val})")
150 }
151
152 fn wire_string(id: u64, s: &str) -> String {
153 format!("(:id {id} :value \"{s}\")")
154 }
155
156 fn wire_error(id: u64, code: &str, msg: &str) -> String {
157 format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
158 }
159
160 fn list_row(cells: Vec<&str>) -> ListRow {
161 ListRow {
162 id: None,
163 cells: cells.into_iter().map(String::from).collect(),
164 }
165 }
166
167 #[test]
168 fn on_reply_scalar_number_becomes_single_row() {
169 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
170 tab.on_reply(&wire_value(1, "42"));
171 assert!(matches!(tab.state, Fetch::Loaded(ref rows) if !rows.is_empty()));
172 }
173
174 #[test]
175 fn on_reply_nil_list_is_loaded_and_empty() {
176 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177 tab.on_reply(&wire_value(1, "nil"));
178 assert!(matches!(tab.state, Fetch::Loaded(ref rows) if rows.is_empty()));
179 }
180
181 #[test]
182 fn on_reply_server_error_becomes_error_state() {
183 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184 tab.on_reply(&wire_error(1, "db", "connection failed"));
185 assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
186 }
187
188 #[test]
189 fn on_reply_string_that_is_not_a_list_is_single_row() {
190 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
191 tab.on_reply(&wire_string(1, "just-a-uuid"));
192 assert!(!matches!(tab.state, Fetch::Idle));
193 }
194
195 #[test]
196 fn select_next_advances_when_loaded() {
197 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
198 tab.state = Fetch::Loaded(vec![
199 list_row(vec!["a"]),
200 list_row(vec!["b"]),
201 list_row(vec!["c"]),
202 ]);
203 assert_eq!(tab.selected, 0);
204 tab.select_next();
205 assert_eq!(tab.selected, 1);
206 tab.select_next();
207 assert_eq!(tab.selected, 2);
208 tab.select_next();
209 assert_eq!(tab.selected, 2, "must not advance past last row");
210 }
211
212 #[test]
213 fn select_prev_saturates_at_zero() {
214 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
215 tab.selected = 0;
216 tab.select_prev();
217 assert_eq!(tab.selected, 0);
218 }
219
220 #[test]
221 fn reset_returns_to_idle() {
222 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
223 tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
224 tab.selected = 3;
225 tab.reset();
226 assert_eq!(tab.state, Fetch::Idle);
227 assert_eq!(tab.selected, 0);
228 assert_eq!(tab.scroll, 0);
229 }
230
231 #[test]
232 fn set_loading_id_updates_state() {
233 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
234 tab.set_loading_id(42);
235 assert_eq!(tab.state, Fetch::Loading { id: 42 });
236 }
237
238 const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
243
244 fn wire_accounts_list() -> String {
246 format!(
247 r#"(:id 1 :value "{}")"#,
248 TYPED_ACCOUNTS.replace('"', "\\\"")
249 )
250 }
251
252 #[test]
253 fn on_reply_list_accounts_rows_carry_ids() {
254 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
255 tab.on_reply(&wire_accounts_list());
256 let Fetch::Loaded(ref rows) = tab.state else {
257 panic!("expected Loaded state");
258 };
259 assert_eq!(rows.len(), 2);
260 assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
261 assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
262 }
263
264 #[test]
265 fn on_reply_list_accounts_projects_schema_cells() {
266 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
267 tab.on_reply(&wire_accounts_list());
268 let Fetch::Loaded(ref rows) = tab.state else {
269 panic!("expected Loaded state");
270 };
271 assert_eq!(rows.len(), 2);
272 assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
274 assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
275 }
276
277 #[test]
278 fn selected_id_returns_focused_row_id() {
279 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
280 tab.state = Fetch::Loaded(vec![
281 ListRow {
282 id: Some("id-A".into()),
283 cells: vec!["A".into()],
284 },
285 ListRow {
286 id: Some("id-B".into()),
287 cells: vec!["B".into()],
288 },
289 ]);
290 tab.selected = 0;
291 assert_eq!(tab.selected_id(), Some("id-A"));
292 tab.selected = 1;
293 assert_eq!(tab.selected_id(), Some("id-B"));
294 }
295
296 #[test]
297 fn selected_id_none_when_not_loaded() {
298 let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
299 assert_eq!(tab.selected_id(), None);
300 }
301
302 #[test]
303 fn selected_id_none_when_row_has_no_id() {
304 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
305 tab.state = Fetch::Loaded(vec![ListRow {
306 id: None,
307 cells: vec!["x".into()],
308 }]);
309 assert_eq!(tab.selected_id(), None);
310 }
311
312 #[test]
313 fn element_without_id_field_has_none_id_cells_still_rendered() {
314 let list_str = r#"((:account :name "NoId" :parent ""))"#;
315 let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
316 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
317 tab.on_reply(&wire);
318 let Fetch::Loaded(ref rows) = tab.state else {
319 panic!("expected Loaded state");
320 };
321 assert_eq!(rows.len(), 1);
322 assert_eq!(rows[0].id, None);
323 assert!(!rows[0].cells.is_empty());
324 }
325
326 #[test]
327 fn select_next_prev_move_selected_id() {
328 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
329 tab.state = Fetch::Loaded(vec![
330 ListRow {
331 id: Some("id-0".into()),
332 cells: vec!["zero".into()],
333 },
334 ListRow {
335 id: Some("id-1".into()),
336 cells: vec!["one".into()],
337 },
338 ListRow {
339 id: Some("id-2".into()),
340 cells: vec!["two".into()],
341 },
342 ]);
343 assert_eq!(tab.selected_id(), Some("id-0"));
344 tab.select_next();
345 assert_eq!(tab.selected_id(), Some("id-1"));
346 tab.select_next();
347 assert_eq!(tab.selected_id(), Some("id-2"));
348 tab.select_prev();
349 assert_eq!(tab.selected_id(), Some("id-1"));
350 }
351}