Skip to main content

tablo_test/protocol/dom/
rows.rs

1//! The rows of a rendered table: their cells, keys, actions, links, tint and editable cells.
2
3use tablo_core::dom::row;
4
5use super::{Attrs, attr_value, elements, is_selected, open_tags, strip_text};
6use crate::protocol::controls::{DisabledAction, disabled_actions};
7
8/// One data row of a rendered table.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Row {
11    /// The bulk checkbox value, the record key when the row is selectable.
12    pub select_value: Option<String>,
13    /// The cell texts in column order, including the bulk and actions cells.
14    pub cells: Vec<String>,
15    /// The row's actions, nested so hrefs have one source.
16    pub actions: RowActions,
17    /// The targets of its cells' links, in document order.
18    pub links: Vec<String>,
19    /// The values its cells' copy buttons copy, in document order.
20    pub copies: Vec<String>,
21    /// The color the table's row color tints it in, by its `#[option(color = "…")]` name.
22    pub color: Option<String>,
23    /// Its cells that edit their field in place, in column order.
24    pub edits: Vec<CellEdit>,
25}
26
27/// One cell editing its field in place: a form posting the field alone to the record's edit
28/// route.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CellEdit {
31    /// The column's name, which is the form key the cell posts.
32    pub column: String,
33    /// Where the form posts: the record's edit route, with the table's return target.
34    pub action: String,
35    /// What the cell posts as rendered: a switch's `true` or `false`, a select's chosen option,
36    /// a text input's value.
37    pub value: Option<String>,
38    /// The record's version the form carries, when the model keeps one.
39    pub version: Option<String>,
40}
41
42/// The action targets of one row.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RowActions {
45    /// The `View` link target, `None` when policy hides it.
46    pub view: Option<String>,
47    /// The `Edit` link target, `None` when policy hides it.
48    pub edit: Option<String>,
49    /// The `Delete` POST target, carried as its button's `formaction`; `None` when policy hides
50    /// it.
51    pub delete_action: Option<String>,
52    /// The targets of the row's [`row_link`](tablo_core::Table::row_link)s, in document order.
53    pub links: Vec<String>,
54    /// The custom actions the row shows disabled, in document order.
55    pub disabled: Vec<DisabledAction>,
56}
57
58/// The data rows of `html` in document order, excluding group headers.
59pub fn rows(html: &str) -> Vec<Row> {
60    let mut out = Vec::new();
61    for (_, attrs, inner) in elements(html, "tr") {
62        let Some(id) = attr_value(&attrs, "id") else {
63            continue;
64        };
65        if !id.starts_with("row-") {
66            continue;
67        }
68        out.push(row(&attrs, &inner));
69    }
70    out
71}
72
73/// One group of a grouped table: its header's label and the rows under it on this page.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Group {
76    /// The label the group's header shows.
77    pub label: String,
78    /// The group's rows on this page, in document order.
79    pub rows: Vec<Row>,
80}
81
82/// The groups of `html` in document order, empty when the table renders ungrouped. A group holds
83/// the rows from its header to the next one.
84pub fn groups(html: &str) -> Vec<Group> {
85    let mut out: Vec<Group> = Vec::new();
86    for (_, attrs, inner) in elements(html, "tr") {
87        if let Some(label) = attr_value(&attrs, "data-group") {
88            out.push(Group {
89                label,
90                rows: Vec::new(),
91            });
92        } else if attr_value(&attrs, "id").is_some_and(|id| id.starts_with("row-"))
93            && let Some(group) = out.last_mut()
94        {
95            group.rows.push(row(&attrs, &inner));
96        }
97    }
98    out
99}
100
101/// The record key of every row in document order, read from its bulk checkbox: a row with no
102/// checkbox has none to read.
103pub fn row_keys(html: &str) -> Vec<String> {
104    rows(html)
105        .into_iter()
106        .filter_map(|row| row.select_value)
107        .collect()
108}
109
110/// The action targets of the row whose checkbox value or action URL carries
111/// `key`, or `None` when no row matches.
112pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
113    rows(html).into_iter().find_map(|row| {
114        let matches = row.select_value.as_deref() == Some(key)
115            || row
116                .actions
117                .view
118                .as_deref()
119                .is_some_and(|href| href_contains_key(href, key))
120            || row
121                .actions
122                .edit
123                .as_deref()
124                .is_some_and(|href| href_contains_key(href, key))
125            || row
126                .actions
127                .delete_action
128                .as_deref()
129                .is_some_and(|action| href_contains_key(action, key));
130        matches.then_some(row.actions)
131    })
132}
133
134fn row(row_attrs: &Attrs, row_html: &str) -> Row {
135    let mut select_value = None;
136    for (tag, attrs, _) in open_tags(row_html) {
137        if tag == "input" && attr_value(&attrs, "data-row-select").is_some() {
138            select_value = attr_value(&attrs, "value");
139            break;
140        }
141    }
142    let mut cells = Vec::new();
143    for (_, _, inner) in elements(row_html, "td") {
144        cells.push(strip_text(&inner));
145    }
146    let mut view_href = None;
147    let mut edit_href = None;
148    let mut delete_action = None;
149    let mut row_links = Vec::new();
150    let mut links = Vec::new();
151    let mut copies = Vec::new();
152    for (_, attrs, _) in open_tags(row_html) {
153        if attr_value(&attrs, "data-cell-link").is_some() {
154            links.extend(attr_value(&attrs, "href"));
155        }
156        if let Some(value) = attr_value(&attrs, "data-copy") {
157            copies.push(value);
158        }
159        match attr_value(&attrs, "data-row-action").as_deref() {
160            Some(row::VIEW) => view_href = attr_value(&attrs, "href"),
161            Some(row::EDIT) => edit_href = attr_value(&attrs, "href"),
162            Some(row::DELETE) => delete_action = attr_value(&attrs, "formaction"),
163            Some(row::LINK) => row_links.extend(attr_value(&attrs, "href")),
164            _ => {}
165        }
166    }
167    Row {
168        select_value,
169        cells,
170        actions: RowActions {
171            view: view_href,
172            edit: edit_href,
173            delete_action,
174            links: row_links,
175            disabled: disabled_actions(row_html),
176        },
177        links,
178        copies,
179        color: attr_value(row_attrs, "data-row-color"),
180        edits: cell_edits(row_html),
181    }
182}
183
184fn cell_edits(row_html: &str) -> Vec<CellEdit> {
185    elements(row_html, "form")
186        .into_iter()
187        .filter_map(|(_, attrs, inner)| {
188            let column = attr_value(&attrs, "data-cell-edit")?;
189            let action = attr_value(&attrs, "action").unwrap_or_default();
190            let mut value = None;
191            let mut version = None;
192            for (tag, attrs, _) in open_tags(&inner) {
193                let name = attr_value(&attrs, "name");
194                let kind = attr_value(&attrs, "type");
195                match (tag.as_str(), name.as_deref(), kind.as_deref()) {
196                    ("input", Some("record_version"), _) => version = attr_value(&attrs, "value"),
197                    ("input", Some(name), Some("checkbox")) if name == column => {
198                        let checked = attrs.iter().any(|(attr, _)| attr == "checked");
199                        value = Some(checked.to_string());
200                    }
201                    ("input", Some(name), kind) if name == column && kind != Some("hidden") => {
202                        value = attr_value(&attrs, "value");
203                    }
204                    _ => {}
205                }
206            }
207            for (_, attrs, _) in open_tags(&inner) {
208                if is_selected(&attrs) {
209                    value = attr_value(&attrs, "value");
210                }
211            }
212            Some(CellEdit {
213                column,
214                action,
215                value,
216                version,
217            })
218        })
219        .collect()
220}
221
222fn href_contains_key(href: &str, key: &str) -> bool {
223    href.split(['?', '#', '/', '&'])
224        .any(|segment| segment == key)
225}