1
//! Generic list-display tab for Accounts, Transactions, Commodities.
2

            
3
use cli_core::render::schema::{self, EntitySchema};
4
use cli_core::render::{ListRow, RenderError, WireValue, parse_wire, reparse_list};
5
use ratatui::Frame;
6
use ratatui::layout::{Constraint, Rect};
7
use ratatui::style::{Color, Modifier, Style};
8
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
9

            
10
use crate::tabs::fetch::Fetch;
11

            
12
pub 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

            
20
impl ListTab {
21
490
    pub fn new(request_form: impl Into<String>, schema: &'static EntitySchema) -> Self {
22
490
        Self {
23
490
            request_form: request_form.into(),
24
490
            schema,
25
490
            state: Fetch::Idle,
26
490
            selected: 0,
27
490
            scroll: 0,
28
490
        }
29
490
    }
30

            
31
    /// Update loading id after the eval assigned a real envelope id.
32
1
    pub fn set_loading_id(&mut self, id: i64) {
33
1
        self.state = Fetch::Loading { id };
34
1
    }
35

            
36
    /// Process a routed reply wire frame and update fetch state.
37
15
    pub fn on_reply(&mut self, wire: &str) {
38
15
        self.state = parse_reply(wire, self.schema);
39
15
    }
40

            
41
    /// Return the entity id of the focused row, or `None` when not loaded or no id.
42
18
    pub fn selected_id(&self) -> Option<&str> {
43
18
        let Fetch::Loaded(ref rows) = self.state else {
44
5
            return None;
45
        };
46
13
        rows.get(self.selected).and_then(|r| r.id.as_deref())
47
18
    }
48

            
49
7
    pub fn select_next(&mut self) {
50
7
        if let Fetch::Loaded(ref rows) = self.state
51
5
            && self.selected + 1 < rows.len()
52
4
        {
53
4
            self.selected += 1;
54
4
        }
55
7
    }
56

            
57
2
    pub fn select_prev(&mut self) {
58
2
        self.selected = self.selected.saturating_sub(1);
59
2
    }
60

            
61
    /// Reset to idle so the next tab-switch re-fetches.
62
4
    pub fn reset(&mut self) {
63
4
        self.state = Fetch::Idle;
64
4
        self.selected = 0;
65
4
        self.scroll = 0;
66
4
    }
67

            
68
    /// Draw the tab using the current fetch state.
69
1
    pub fn draw(&self, frame: &mut Frame, area: Rect, title: &str) {
70
1
        let block = Block::default().borders(Borders::ALL).title(title);
71
1
        match &self.state {
72
1
            Fetch::Idle => {
73
1
                frame.render_widget(Paragraph::new("Waiting...").block(block), area);
74
1
            }
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
1
    }
88
}
89

            
90
15
fn parse_reply(wire: &str, entity_schema: &EntitySchema) -> Fetch<Vec<ListRow>> {
91
15
    match parse_wire(wire) {
92
9
        Ok(WireValue::Value(scripting::nomiscript::Value::String(ref s))) => {
93
9
            match reparse_list(s) {
94
9
                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
2
        Ok(WireValue::Value(ref v)) => Fetch::Loaded(schema::project_rows(v, entity_schema)),
100
4
        Err(RenderError::Server { code, message }) => Fetch::Error(format!("[{code}] {message}")),
101
        Err(e) => Fetch::Error(e.to_string()),
102
    }
103
15
}
104

            
105
fn 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)]
143
mod tests {
144
    use cli_core::render::schema;
145

            
146
    use super::*;
147

            
148
2
    fn wire_value(id: u64, val: &str) -> String {
149
2
        format!("(:id {id} :value {val})")
150
2
    }
151

            
152
1
    fn wire_string(id: u64, s: &str) -> String {
153
1
        format!("(:id {id} :value \"{s}\")")
154
1
    }
155

            
156
1
    fn wire_error(id: u64, code: &str, msg: &str) -> String {
157
1
        format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
158
1
    }
159

            
160
4
    fn list_row(cells: Vec<&str>) -> ListRow {
161
4
        ListRow {
162
4
            id: None,
163
4
            cells: cells.into_iter().map(String::from).collect(),
164
4
        }
165
4
    }
166

            
167
    #[test]
168
1
    fn on_reply_scalar_number_becomes_single_row() {
169
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
170
1
        tab.on_reply(&wire_value(1, "42"));
171
1
        assert!(matches!(tab.state, Fetch::Loaded(ref rows) if !rows.is_empty()));
172
1
    }
173

            
174
    #[test]
175
1
    fn on_reply_nil_list_is_loaded_and_empty() {
176
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177
1
        tab.on_reply(&wire_value(1, "nil"));
178
1
        assert!(matches!(tab.state, Fetch::Loaded(ref rows) if rows.is_empty()));
179
1
    }
180

            
181
    #[test]
182
1
    fn on_reply_server_error_becomes_error_state() {
183
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184
1
        tab.on_reply(&wire_error(1, "db", "connection failed"));
185
1
        assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
186
1
    }
187

            
188
    #[test]
189
1
    fn on_reply_string_that_is_not_a_list_is_single_row() {
190
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
191
1
        tab.on_reply(&wire_string(1, "just-a-uuid"));
192
1
        assert!(!matches!(tab.state, Fetch::Idle));
193
1
    }
194

            
195
    #[test]
196
1
    fn select_next_advances_when_loaded() {
197
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
198
1
        tab.state = Fetch::Loaded(vec![
199
1
            list_row(vec!["a"]),
200
1
            list_row(vec!["b"]),
201
1
            list_row(vec!["c"]),
202
1
        ]);
203
1
        assert_eq!(tab.selected, 0);
204
1
        tab.select_next();
205
1
        assert_eq!(tab.selected, 1);
206
1
        tab.select_next();
207
1
        assert_eq!(tab.selected, 2);
208
1
        tab.select_next();
209
1
        assert_eq!(tab.selected, 2, "must not advance past last row");
210
1
    }
211

            
212
    #[test]
213
1
    fn select_prev_saturates_at_zero() {
214
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
215
1
        tab.selected = 0;
216
1
        tab.select_prev();
217
1
        assert_eq!(tab.selected, 0);
218
1
    }
219

            
220
    #[test]
221
1
    fn reset_returns_to_idle() {
222
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
223
1
        tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
224
1
        tab.selected = 3;
225
1
        tab.reset();
226
1
        assert_eq!(tab.state, Fetch::Idle);
227
1
        assert_eq!(tab.selected, 0);
228
1
        assert_eq!(tab.scroll, 0);
229
1
    }
230

            
231
    #[test]
232
1
    fn set_loading_id_updates_state() {
233
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
234
1
        tab.set_loading_id(42);
235
1
        assert_eq!(tab.state, Fetch::Loading { id: 42 });
236
1
    }
237

            
238
    // --- L4: structured ListRow tests ---
239

            
240
    // Real list-accounts replies are typed records with a leading `:account`
241
    // tag (exactly as `render_entity` emits), so `:id` sits after the tag.
242
    const TYPED_ACCOUNTS: &str = r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#;
243

            
244
    /// Wire reply carrying the typed-record list as the `:value` string payload.
245
2
    fn wire_accounts_list() -> String {
246
2
        format!(
247
            r#"(:id 1 :value "{}")"#,
248
2
            TYPED_ACCOUNTS.replace('"', "\\\"")
249
        )
250
2
    }
251

            
252
    #[test]
253
1
    fn on_reply_list_accounts_rows_carry_ids() {
254
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
255
1
        tab.on_reply(&wire_accounts_list());
256
1
        let Fetch::Loaded(ref rows) = tab.state else {
257
            panic!("expected Loaded state");
258
        };
259
1
        assert_eq!(rows.len(), 2);
260
1
        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
261
1
        assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
262
1
    }
263

            
264
    #[test]
265
1
    fn on_reply_list_accounts_projects_schema_cells() {
266
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
267
1
        tab.on_reply(&wire_accounts_list());
268
1
        let Fetch::Loaded(ref rows) = tab.state else {
269
            panic!("expected Loaded state");
270
        };
271
1
        assert_eq!(rows.len(), 2);
272
        // Name filled, Type/Parent blank (absent from these records)
273
1
        assert_eq!(rows[0].cells, vec!["Checking", "", ""]);
274
1
        assert_eq!(rows[1].cells, vec!["Savings", "", ""]);
275
1
    }
276

            
277
    #[test]
278
1
    fn selected_id_returns_focused_row_id() {
279
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
280
1
        tab.state = Fetch::Loaded(vec![
281
1
            ListRow {
282
1
                id: Some("id-A".into()),
283
1
                cells: vec!["A".into()],
284
1
            },
285
1
            ListRow {
286
1
                id: Some("id-B".into()),
287
1
                cells: vec!["B".into()],
288
1
            },
289
1
        ]);
290
1
        tab.selected = 0;
291
1
        assert_eq!(tab.selected_id(), Some("id-A"));
292
1
        tab.selected = 1;
293
1
        assert_eq!(tab.selected_id(), Some("id-B"));
294
1
    }
295

            
296
    #[test]
297
1
    fn selected_id_none_when_not_loaded() {
298
1
        let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
299
1
        assert_eq!(tab.selected_id(), None);
300
1
    }
301

            
302
    #[test]
303
1
    fn selected_id_none_when_row_has_no_id() {
304
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
305
1
        tab.state = Fetch::Loaded(vec![ListRow {
306
1
            id: None,
307
1
            cells: vec!["x".into()],
308
1
        }]);
309
1
        assert_eq!(tab.selected_id(), None);
310
1
    }
311

            
312
    #[test]
313
1
    fn element_without_id_field_has_none_id_cells_still_rendered() {
314
1
        let list_str = r#"((:account :name "NoId" :parent ""))"#;
315
1
        let wire = format!(r#"(:id 1 :value "{}")"#, list_str.replace('"', "\\\""));
316
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
317
1
        tab.on_reply(&wire);
318
1
        let Fetch::Loaded(ref rows) = tab.state else {
319
            panic!("expected Loaded state");
320
        };
321
1
        assert_eq!(rows.len(), 1);
322
1
        assert_eq!(rows[0].id, None);
323
1
        assert!(!rows[0].cells.is_empty());
324
1
    }
325

            
326
    #[test]
327
1
    fn select_next_prev_move_selected_id() {
328
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
329
1
        tab.state = Fetch::Loaded(vec![
330
1
            ListRow {
331
1
                id: Some("id-0".into()),
332
1
                cells: vec!["zero".into()],
333
1
            },
334
1
            ListRow {
335
1
                id: Some("id-1".into()),
336
1
                cells: vec!["one".into()],
337
1
            },
338
1
            ListRow {
339
1
                id: Some("id-2".into()),
340
1
                cells: vec!["two".into()],
341
1
            },
342
1
        ]);
343
1
        assert_eq!(tab.selected_id(), Some("id-0"));
344
1
        tab.select_next();
345
1
        assert_eq!(tab.selected_id(), Some("id-1"));
346
1
        tab.select_next();
347
1
        assert_eq!(tab.selected_id(), Some("id-2"));
348
1
        tab.select_prev();
349
1
        assert_eq!(tab.selected_id(), Some("id-1"));
350
1
    }
351
}