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_server_error_becomes_error_state() {
176 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177 tab.on_reply(&wire_error(1, "db", "connection failed"));
178 assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
179 }
180
181 #[test]
182 fn on_reply_string_that_is_not_a_list_is_single_row() {
183 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184 tab.on_reply(&wire_string(1, "just-a-uuid"));
185 assert!(!matches!(tab.state, Fetch::Idle));
186 }
187
188 #[test]
189 fn select_next_advances_when_loaded() {
190 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
191 tab.state = Fetch::Loaded(vec![
192 list_row(vec!["a"]),
193 list_row(vec!["b"]),
194 list_row(vec!["c"]),
195 ]);
196 assert_eq!(tab.selected, 0);
197 tab.select_next();
198 assert_eq!(tab.selected, 1);
199 tab.select_next();
200 assert_eq!(tab.selected, 2);
201 tab.select_next();
202 assert_eq!(tab.selected, 2, "must not advance past last row");
203 }
204
205 #[test]
206 fn select_prev_saturates_at_zero() {
207 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
208 tab.selected = 0;
209 tab.select_prev();
210 assert_eq!(tab.selected, 0);
211 }
212
213 #[test]
214 fn reset_returns_to_idle() {
215 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
216 tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
217 tab.selected = 3;
218 tab.reset();
219 assert_eq!(tab.state, Fetch::Idle);
220 assert_eq!(tab.selected, 0);
221 assert_eq!(tab.scroll, 0);
222 }
223
224 #[test]
225 fn set_loading_id_updates_state() {
226 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
227 tab.set_loading_id(42);
228 assert_eq!(tab.state, Fetch::Loading { id: 42 });
229 }
230
231 const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
236
237 fn wire_accounts_list() -> String {
239 format!(
240 r#"(:id 1 :value "{}")"#,
241 TYPED_ACCOUNTS.replace('"', "\\\"")
242 )
243 }
244
245 #[test]
246 fn on_reply_list_accounts_rows_carry_ids() {
247 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
248 tab.on_reply(&wire_accounts_list());
249 let Fetch::Loaded(ref rows) = tab.state else {
250 panic!("expected Loaded state");
251 };
252 assert_eq!(rows.len(), 2);
253 assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
254 assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
255 }
256
257 #[test]
258 fn on_reply_list_accounts_projects_schema_cells() {
259 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
260 tab.on_reply(&wire_accounts_list());
261 let Fetch::Loaded(ref rows) = tab.state else {
262 panic!("expected Loaded state");
263 };
264 assert_eq!(rows.len(), 2);
265 assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
267 assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
268 }
269
270 #[test]
271 fn selected_id_returns_focused_row_id() {
272 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
273 tab.state = Fetch::Loaded(vec![
274 ListRow {
275 id: Some("id-A".into()),
276 cells: vec!["A".into()],
277 },
278 ListRow {
279 id: Some("id-B".into()),
280 cells: vec!["B".into()],
281 },
282 ]);
283 tab.selected = 0;
284 assert_eq!(tab.selected_id(), Some("id-A"));
285 tab.selected = 1;
286 assert_eq!(tab.selected_id(), Some("id-B"));
287 }
288
289 #[test]
290 fn selected_id_none_when_not_loaded() {
291 let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
292 assert_eq!(tab.selected_id(), None);
293 }
294
295 #[test]
296 fn selected_id_none_when_row_has_no_id() {
297 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
298 tab.state = Fetch::Loaded(vec![ListRow {
299 id: None,
300 cells: vec!["x".into()],
301 }]);
302 assert_eq!(tab.selected_id(), None);
303 }
304
305 #[test]
306 fn element_without_id_field_has_none_id_cells_still_rendered() {
307 let list_str = r#"((:account :name "NoId" :parent ""))"#;
308 let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
309 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
310 tab.on_reply(&wire);
311 let Fetch::Loaded(ref rows) = tab.state else {
312 panic!("expected Loaded state");
313 };
314 assert_eq!(rows.len(), 1);
315 assert_eq!(rows[0].id, None);
316 assert!(!rows[0].cells.is_empty());
317 }
318
319 #[test]
320 fn select_next_prev_move_selected_id() {
321 let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
322 tab.state = Fetch::Loaded(vec![
323 ListRow {
324 id: Some("id-0".into()),
325 cells: vec!["zero".into()],
326 },
327 ListRow {
328 id: Some("id-1".into()),
329 cells: vec!["one".into()],
330 },
331 ListRow {
332 id: Some("id-2".into()),
333 cells: vec!["two".into()],
334 },
335 ]);
336 assert_eq!(tab.selected_id(), Some("id-0"));
337 tab.select_next();
338 assert_eq!(tab.selected_id(), Some("id-1"));
339 tab.select_next();
340 assert_eq!(tab.selected_id(), Some("id-2"));
341 tab.select_prev();
342 assert_eq!(tab.selected_id(), Some("id-1"));
343 }
344}