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
489
    pub fn new(request_form: impl Into<String>, schema: &'static EntitySchema) -> Self {
22
489
        Self {
23
489
            request_form: request_form.into(),
24
489
            schema,
25
489
            state: Fetch::Idle,
26
489
            selected: 0,
27
489
            scroll: 0,
28
489
        }
29
489
    }
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
14
    pub fn on_reply(&mut self, wire: &str) {
38
14
        self.state = parse_reply(wire, self.schema);
39
14
    }
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
14
fn parse_reply(wire: &str, entity_schema: &EntitySchema) -> Fetch<Vec<ListRow>> {
91
14
    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
1
        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
14
}
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
1
    fn wire_value(id: u64, val: &str) -> String {
149
1
        format!("(:id {id} :value {val})")
150
1
    }
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_server_error_becomes_error_state() {
176
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
177
1
        tab.on_reply(&wire_error(1, "db", "connection failed"));
178
1
        assert!(matches!(tab.state, Fetch::Error(ref s) if s.contains("db")));
179
1
    }
180

            
181
    #[test]
182
1
    fn on_reply_string_that_is_not_a_list_is_single_row() {
183
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
184
1
        tab.on_reply(&wire_string(1, "just-a-uuid"));
185
1
        assert!(!matches!(tab.state, Fetch::Idle));
186
1
    }
187

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

            
205
    #[test]
206
1
    fn select_prev_saturates_at_zero() {
207
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
208
1
        tab.selected = 0;
209
1
        tab.select_prev();
210
1
        assert_eq!(tab.selected, 0);
211
1
    }
212

            
213
    #[test]
214
1
    fn reset_returns_to_idle() {
215
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
216
1
        tab.state = Fetch::Loaded(vec![list_row(vec!["x"])]);
217
1
        tab.selected = 3;
218
1
        tab.reset();
219
1
        assert_eq!(tab.state, Fetch::Idle);
220
1
        assert_eq!(tab.selected, 0);
221
1
        assert_eq!(tab.scroll, 0);
222
1
    }
223

            
224
    #[test]
225
1
    fn set_loading_id_updates_state() {
226
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
227
1
        tab.set_loading_id(42);
228
1
        assert_eq!(tab.state, Fetch::Loading { id: 42 });
229
1
    }
230

            
231
    // --- L4: structured ListRow tests ---
232

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

            
237
    /// Wire reply carrying the typed-record list as the `:value` string payload.
238
2
    fn wire_accounts_list() -> String {
239
2
        format!(
240
            r#"(:id 1 :value "{}")"#,
241
2
            TYPED_ACCOUNTS.replace('"', "\\\"")
242
        )
243
2
    }
244

            
245
    #[test]
246
1
    fn on_reply_list_accounts_rows_carry_ids() {
247
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
248
1
        tab.on_reply(&wire_accounts_list());
249
1
        let Fetch::Loaded(ref rows) = tab.state else {
250
            panic!("expected Loaded state");
251
        };
252
1
        assert_eq!(rows.len(), 2);
253
1
        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
254
1
        assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
255
1
    }
256

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

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

            
289
    #[test]
290
1
    fn selected_id_none_when_not_loaded() {
291
1
        let tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
292
1
        assert_eq!(tab.selected_id(), None);
293
1
    }
294

            
295
    #[test]
296
1
    fn selected_id_none_when_row_has_no_id() {
297
1
        let mut tab = ListTab::new("(list-accounts)", &schema::ACCOUNTS);
298
1
        tab.state = Fetch::Loaded(vec![ListRow {
299
1
            id: None,
300
1
            cells: vec!["x".into()],
301
1
        }]);
302
1
        assert_eq!(tab.selected_id(), None);
303
1
    }
304

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

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